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.
This commit is contained in:
parent
5102ed23c1
commit
ce7e400720
@ -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 (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||
<Link href="/(tabs)/(explore)" asChild>
|
||||
<Pressable style={{ marginRight: theme.spacing.xs }}>
|
||||
<Text style={{ fontSize: 20, color: theme.colors.primary }}>← Сите</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
<Text style={{ fontSize: 28 }}>{catEmoji}</Text>
|
||||
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
|
||||
{catName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{categoryAds === undefined ? (
|
||||
<Loading />
|
||||
) : categoryAds.length === 0 ? (
|
||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||
Нема огласи во оваа категорија
|
||||
</Text>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
{categoryAds.map((ad) => (
|
||||
<AdCard key={ad._id} ad={ad} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
||||
>
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<View key={cat.slug} style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
minWidth: "45%",
|
||||
alignItems: "center",
|
||||
}}>
|
||||
<Text style={{ fontSize: 28, marginBottom: 4 }}>{cat.icon === "construct-outline" ? "🔧" : "📋"}</Text>
|
||||
<Text style={{ color: theme.colors.text, fontSize: 14, fontWeight: "500" }}>{cat.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text }}>
|
||||
Пребарувај по категорија
|
||||
</Text>
|
||||
|
||||
<CategoryGrid categories={displayCategories} />
|
||||
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
|
||||
Сите огласи
|
||||
</Text>
|
||||
|
||||
{allAds === undefined ? (
|
||||
<Loading />
|
||||
) : allAds.length === 0 ? (
|
||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||
Нема огласи
|
||||
</Text>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
{allAds.map((ad) => (
|
||||
<AdCard key={ad._id} ad={ad} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<ScrollView
|
||||
@ -20,38 +36,48 @@ export default function HomeScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Link href="/(tabs)/(explore)" asChild>
|
||||
<Pressable style={{
|
||||
<TextInput
|
||||
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,
|
||||
}}>
|
||||
<Text style={{ color: theme.colors.textTertiary, fontSize: 16 }}>🔍 Пребарувај мајстори...</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
}}
|
||||
/>
|
||||
|
||||
{!search.trim() && (
|
||||
<>
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
|
||||
Категории
|
||||
</Text>
|
||||
<CategoryGrid categories={displayCategories} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
|
||||
Категории
|
||||
{search.trim() ? "Резултати од пребарување" : "Неодамна додадени"}
|
||||
</Text>
|
||||
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
|
||||
<Pressable style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
}}>
|
||||
<Text style={{ color: theme.colors.text, fontSize: 14 }}>{cat.name}</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
))}
|
||||
</View>
|
||||
{ads === undefined ? (
|
||||
<Loading />
|
||||
) : ads.length === 0 ? (
|
||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||
{search.trim() ? "Не се пронајдени резултати" : "Нема огласи"}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
{ads.map((ad) => (
|
||||
<AdCard key={ad._id} ad={ad} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
58
components/ad-card.tsx
Normal file
58
components/ad-card.tsx
Normal file
@ -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 (
|
||||
<Link href={`/ad/${ad._id}` as any} asChild>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.radius.lg,
|
||||
padding: theme.spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing.sm,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text, flex: 1 }}>
|
||||
{ad.title}
|
||||
</Text>
|
||||
{ad.priceRange && (
|
||||
<Text 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" }}>
|
||||
<Badge label={categoryName} variant="primary" />
|
||||
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {ad.location}</Text>
|
||||
</View>
|
||||
|
||||
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
||||
<Rating value={ad.ratingAvg} count={ad.reviewCount} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
57
components/category-grid.tsx
Normal file
57
components/category-grid.tsx
Normal file
@ -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 (
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
|
||||
{categories.map((cat) => {
|
||||
const emoji = CATEGORY_EMOJI[cat.slug] ?? cat.icon ?? "📋";
|
||||
return (
|
||||
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
|
||||
<Pressable
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
width: `${Math.floor((100 - (columns - 1) * 3.3) / columns)}%` as any,
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 28 }}>{emoji}</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{cat.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
},
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -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"];
|
||||
export type CategorySlug = (typeof CATEGORIES)[number]["slug"];
|
||||
|
||||
export const CATEGORY_EMOJI: Record<string, string> = {
|
||||
"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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user