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 { 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() {
|
export default function ExploreScreen() {
|
||||||
const theme = useTheme();
|
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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
|
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text }}>
|
||||||
{CATEGORIES.map((cat) => (
|
Пребарувај по категорија
|
||||||
<View key={cat.slug} style={{
|
</Text>
|
||||||
backgroundColor: theme.colors.surface,
|
|
||||||
borderRadius: theme.radius.md,
|
<CategoryGrid categories={displayCategories} />
|
||||||
paddingHorizontal: theme.spacing.md,
|
|
||||||
paddingVertical: theme.spacing.md,
|
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
|
||||||
borderWidth: 1,
|
Сите огласи
|
||||||
borderColor: theme.colors.border,
|
</Text>
|
||||||
minWidth: "45%",
|
|
||||||
alignItems: "center",
|
{allAds === undefined ? (
|
||||||
}}>
|
<Loading />
|
||||||
<Text style={{ fontSize: 28, marginBottom: 4 }}>{cat.icon === "construct-outline" ? "🔧" : "📋"}</Text>
|
) : allAds.length === 0 ? (
|
||||||
<Text style={{ color: theme.colors.text, fontSize: 14, fontWeight: "500" }}>{cat.name}</Text>
|
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||||
</View>
|
Нема огласи
|
||||||
))}
|
</Text>
|
||||||
</View>
|
) : (
|
||||||
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
|
{allAds.map((ad) => (
|
||||||
|
<AdCard key={ad._id} ad={ad} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,10 +1,26 @@
|
|||||||
import { View, Text, Pressable, ScrollView } from "react-native";
|
import { View, Text, TextInput, ScrollView } from "react-native";
|
||||||
import { Link } from "expo-router";
|
import { useState } from "react";
|
||||||
import { useTheme } from "../../../components/theme";
|
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";
|
import { CATEGORIES } from "../../../lib/constants";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const theme = useTheme();
|
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 (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@ -20,38 +36,48 @@ export default function HomeScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Link href="/(tabs)/(explore)" asChild>
|
<TextInput
|
||||||
<Pressable style={{
|
placeholder="Пребарувај мајстори..."
|
||||||
|
placeholderTextColor={theme.colors.textTertiary}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surface,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
padding: theme.spacing.md,
|
padding: theme.spacing.md,
|
||||||
|
fontSize: 16,
|
||||||
|
color: theme.colors.text,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
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 }}>
|
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
|
||||||
Категории
|
{search.trim() ? "Резултати од пребарување" : "Неодамна додадени"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
|
{ads === undefined ? (
|
||||||
{CATEGORIES.map((cat) => (
|
<Loading />
|
||||||
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
|
) : ads.length === 0 ? (
|
||||||
<Pressable style={{
|
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||||
backgroundColor: theme.colors.surface,
|
{search.trim() ? "Не се пронајдени резултати" : "Нема огласи"}
|
||||||
borderRadius: theme.radius.md,
|
</Text>
|
||||||
paddingHorizontal: theme.spacing.md,
|
) : (
|
||||||
paddingVertical: theme.spacing.sm,
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
borderWidth: 1,
|
{ads.map((ad) => (
|
||||||
borderColor: theme.colors.border,
|
<AdCard key={ad._id} ad={ad} />
|
||||||
}}>
|
))}
|
||||||
<Text style={{ color: theme.colors.text, fontSize: 14 }}>{cat.name}</Text>
|
</View>
|
||||||
</Pressable>
|
)}
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</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 { query } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export const search = query({
|
export const list = query({
|
||||||
args: { query: v.string(), category: v.optional(v.string()) },
|
args: {},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx) => {
|
||||||
if (args.category) {
|
return await ctx.db.query("ads").order("desc").collect();
|
||||||
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 getByHandyman = query({
|
export const getByCategory = query({
|
||||||
args: { handymanId: v.id("users") },
|
args: { category: v.string() },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
return await ctx.db
|
return await ctx.db
|
||||||
.query("ads")
|
.query("ads")
|
||||||
.withIndex("by_handyman", (q) => q.eq("handymanId", args.handymanId))
|
.withIndex("by_category", (q) => q.eq("category", args.category))
|
||||||
|
.order("desc")
|
||||||
.collect();
|
.collect();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -31,3 +25,38 @@ export const getById = query({
|
|||||||
return await ctx.db.get(args.id);
|
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,4 +1,20 @@
|
|||||||
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({
|
export const list = query({
|
||||||
args: {},
|
args: {},
|
||||||
@ -6,3 +22,28 @@ export const list = query({
|
|||||||
return await ctx.db.query("categories").withIndex("by_slug").order("asc").collect();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -14,3 +14,22 @@ export const CATEGORIES = [
|
|||||||
] as const;
|
] 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