From ff464b0f9b8df35067fa9413dcf1877c1f34944d Mon Sep 17 00:00:00 2001 From: echo Date: Fri, 29 May 2026 18:28:01 +0200 Subject: [PATCH] feat: Phase 3 - ad creation, ad detail, profile my-ads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add create/update/remove ad mutations with auth & ownership checks - Ad detail screen: gallery placeholder, title, description, category badge, location, price range, availability, rating, handyman info card, start-chat and write-review buttons for non-owners, edit/delete for ad owner, auth redirect for anonymous - Create ad screen: 4-step form (category → title/description → location → price/availability/submit), handyman-only guard, edit mode via editId param - Profile screen: user avatar/name/role/email/phone, my-ads section for handymen with edit/delete, new-ad button, placeholder my-posts section for customers, proper logout mutation All UI text in Macedonian. TypeScript clean, Convex functions deployed. --- app/(tabs)/(profile)/index.tsx | 178 ++++++++++++++-- app/ad/[id].tsx | 185 ++++++++++++++++- app/ad/create.tsx | 370 ++++++++++++++++++++++++++++++++- convex/ads.ts | 110 +++++++++- 4 files changed, 803 insertions(+), 40 deletions(-) diff --git a/app/(tabs)/(profile)/index.tsx b/app/(tabs)/(profile)/index.tsx index 295fdae..34f9796 100644 --- a/app/(tabs)/(profile)/index.tsx +++ b/app/(tabs)/(profile)/index.tsx @@ -1,16 +1,28 @@ -import { View, Text, Pressable, ScrollView } from "react-native"; +import { View, Text, Pressable, ScrollView, Alert } from "react-native"; import { useRouter } from "expo-router"; import { useQuery, useMutation } from "convex/react"; import { api } from "../../../convex/_generated/api"; 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 { getCategoryName } from "../../../lib/constants"; export default function ProfileScreen() { const router = useRouter(); const theme = useTheme(); - const { token, isAuthenticated, logout } = useAuth(); + const { token, userId, isAuthenticated, logout } = useAuth(); const user = useQuery(api.users.getCurrentUser, isAuthenticated ? { token: token! } : "skip"); + const myAds = useQuery( + api.ads.getByHandyman, + isAuthenticated && userId ? { handymanId: userId as any } : "skip" + ); + const deleteAd = useMutation(api.ads.remove); const logoutMutation = useMutation(api.auth.logout); async function handleLogout() { @@ -23,6 +35,27 @@ export default function ProfileScreen() { router.replace("/(auth)/login"); } + async function handleDeleteAd(adId: string) { + Alert.alert( + "Избриши оглас", + "Дали сте сигурни дека сакате да го избришете овој оглас?", + [ + { text: "Откажи", style: "cancel" }, + { + text: "Избриши", + style: "destructive", + onPress: async () => { + try { + await deleteAd({ token: token!, adId: adId as any }); + } catch (e: any) { + Alert.alert("Грешка", e.message || "Неуспешно бришење"); + } + }, + }, + ] + ); + } + if (!isAuthenticated) { return ( @@ -38,33 +71,132 @@ export default function ProfileScreen() { ); } + if (!user) { + return ( + + + + ); + } + + const isHandyman = user.role === "handyman"; + return ( - + + {/* Profile header */} - - - {user?.name?.charAt(0)?.toUpperCase() || "?"} - - + - {user?.name || "Корисник"} - - - {user?.role === "handyman" ? "Мајстор" : "Клиент"} + {user.name || "Корисник"} + + {user.email && ( + + {user.email} + + )} + {user.phone && ( + + {user.phone} + + )} - - - Мои огласи - + {/* Handyman sections */} + {isHandyman && ( + <> + + + Мои огласи + + router.push("/ad/create" as any)}> + + + Нов оглас + + + + + {myAds === undefined ? ( + + ) : myAds.length === 0 ? ( + + + 📋 + + Сеуште немате огласи. Креирајте нов оглас за да се прикажете на мајсторите. + + + + ) : ( + + {myAds.map((ad) => ( + + + + router.push(`/ad/create?editId=${ad._id}` as any)} + style={{ + flex: 1, + backgroundColor: theme.colors.surface, + borderRadius: theme.radius.md, + paddingVertical: 10, + alignItems: "center", + borderWidth: 1, + borderColor: theme.colors.border, + }} + > + Уреди + + handleDeleteAd(ad._id)} + style={{ + flex: 1, + backgroundColor: theme.colors.errorLight, + borderRadius: theme.radius.md, + paddingVertical: 10, + alignItems: "center", + }} + > + Избриши + + + + ))} + + )} + + )} + + {/* Customer sections */} + {!isHandyman && ( + <> + + + Мои побарувања + + + + + Ново побарување + + + + + + + 📝 + + Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор. + + + + + )} + + {/* Menu items */} + Оцени diff --git a/app/ad/[id].tsx b/app/ad/[id].tsx index 2dbf766..6a4d0d5 100644 --- a/app/ad/[id].tsx +++ b/app/ad/[id].tsx @@ -1,21 +1,190 @@ -import { View, Text, ScrollView } from "react-native"; -import { useLocalSearchParams, Stack } from "expo-router"; +import { View, Text, ScrollView, Pressable, Alert } from "react-native"; +import { useLocalSearchParams, Stack, useRouter } from "expo-router"; +import { useQuery, useMutation } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import { useAuth } from "../_layout"; import { useTheme } from "../../components/theme"; +import { Badge } from "../../components/ui/badge"; +import { Rating } from "../../components/ui/rating"; +import { Button } from "../../components/ui/button"; +import { Avatar } from "../../components/ui/avatar"; +import { Loading } from "../../components/ui/loading"; +import { Card } from "../../components/ui/card"; +import { getCategoryName, CATEGORY_EMOJI } from "../../lib/constants"; export default function AdDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const theme = useTheme(); + const router = useRouter(); + const { token, isAuthenticated, userId } = useAuth(); + + const ad = useQuery(api.ads.getById, id ? { id: id as any } : "skip"); + const handyman = useQuery( + api.users.getById, + ad ? { id: ad.handymanId } : "skip" + ); + const deleteAd = useMutation(api.ads.remove); + + if (!ad) { + return ( + <> + + + + + + ); + } + + const isOwner = userId === ad.handymanId; + const categoryName = getCategoryName(ad.category); + const categoryEmoji = CATEGORY_EMOJI[ad.category] || "📋"; + + function handleDelete() { + Alert.alert( + "Избриши оглас", + "Дали сте сигурни дека сакате да го избришете овој оглас?", + [ + { text: "Откажи", style: "cancel" }, + { + text: "Избриши", + style: "destructive", + onPress: async () => { + try { + if (!ad) return; + await deleteAd({ token: token!, adId: ad._id as any }); + router.back(); + } catch (e: any) { + Alert.alert("Грешка", e.message || "Неуспешно бришење"); + } + }, + }, + ] + ); + } return ( <> - - - - 📋 - - Оглас {id} + 20 ? ad.title.slice(0, 20) + "…" : ad.title, + headerShown: true, + headerBackButtonDisplayMode: "minimal", + }} + /> + + {/* Gallery placeholder */} + + {categoryEmoji} + + Нема слики + + {/* Title + Price */} + + {ad.title} + {ad.priceRange && ( + + {ad.priceRange} + + )} + + + {/* Category + Location */} + + + 📍 {ad.location} + + + {/* Rating */} + {ad.ratingAvg != null && ad.ratingAvg > 0 && ( + + )} + + {/* Availability */} + {ad.availability && ( + + + Расположивост + + {ad.availability} + + )} + + {/* Description */} + + + Опис + + {ad.description} + + + {/* Handyman info */} + + { + if (handyman) router.push(`/(tabs)/(profile)`); + }} + style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.md }} + > + + + + {handyman?.name || "Мајстор"} + + Мајстор + + + + + {/* Action buttons */} + {!isOwner && isAuthenticated && ( + + + + + )} + + {/* Owner actions */} + {isOwner && ( + + + + + )} + + {!isOwner && !isAuthenticated && ( + + + Најавете се за да започнете разговор или да напишете оценка. + + + + + + )} ); diff --git a/app/ad/create.tsx b/app/ad/create.tsx index bdf1175..1a91b94 100644 --- a/app/ad/create.tsx +++ b/app/ad/create.tsx @@ -1,19 +1,373 @@ -import { View, Text, ScrollView } from "react-native"; -import { Stack } from "expo-router"; +import { View, Text, ScrollView, Pressable, Alert } from "react-native"; +import { useState } from "react"; +import { Stack, useRouter, useLocalSearchParams } from "expo-router"; +import { useQuery, useMutation } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import { useAuth } from "../_layout"; import { useTheme } from "../../components/theme"; +import { Button } from "../../components/ui/button"; +import { Input } from "../../components/ui/input"; +import { Badge } from "../../components/ui/badge"; +import { Loading } from "../../components/ui/loading"; +import { CATEGORIES, CATEGORY_EMOJI, getCategoryName } from "../../lib/constants"; export default function CreateAdScreen() { const theme = useTheme(); + const router = useRouter(); + const { token, isAuthenticated, userId } = useAuth(); + const params = useLocalSearchParams<{ editId?: string }>(); + + const isEditing = !!params.editId; + const existingAd = useQuery( + api.ads.getById, + isEditing ? { id: params.editId as any } : "skip" + ); + + const user = useQuery( + api.users.getCurrentUser, + isAuthenticated ? { token: token! } : "skip" + ); + const createAd = useMutation(api.ads.create); + const updateAd = useMutation(api.ads.update); + + const [category, setCategory] = useState(""); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [location, setLocation] = useState(""); + const [priceRange, setPriceRange] = useState(""); + const [availability, setAvailability] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [step, setStep] = useState(0); + + const isHandyman = user?.role === "handyman"; + + const canProceed = step === 0 + ? !!category + : step === 1 + ? title.trim().length >= 3 && description.trim().length >= 10 + : step === 2 + ? location.trim().length > 0 + : true; + + if (!isAuthenticated) { + return ( + <> + + + 🔒 + + Најава потребна + + + Треба да бидете најавени за да креирате оглас. + + + + + ); + } + + if (user && !isHandyman) { + return ( + <> + + + 🚫 + + Само за мајстори + + + Само мајстори можат да креираат огласи. + + + + ); + } + + if (user === undefined) { + return ( + <> + + + + + + ); + } + + async function handleSubmit() { + if (submitting) return; + setSubmitting(true); + + try { + if (isEditing && existingAd) { + await updateAd({ + token: token!, + adId: existingAd._id as any, + title: title.trim(), + description: description.trim(), + category, + location: location.trim(), + priceRange: priceRange.trim() || undefined, + availability: availability.trim() || undefined, + }); + } else { + const adId = await createAd({ + token: token!, + title: title.trim(), + description: description.trim(), + category, + location: location.trim(), + priceRange: priceRange.trim() || undefined, + availability: availability.trim() || undefined, + }); + router.replace(`/ad/${adId}`); + return; + } + router.back(); + } catch (e: any) { + Alert.alert("Грешка", e.message || "Неуспешно зачувување"); + } finally { + setSubmitting(false); + } + } return ( <> - - - - - Креирање оглас — доаѓа во Фаза 3 - + + + {/* Step indicator */} + + {["Категорија", "Детали", "Локација", "Цена"].map((label, i) => ( + setStep(i)} + style={{ + flex: 1, + paddingVertical: theme.spacing.sm, + paddingHorizontal: theme.spacing.xs, + borderRadius: theme.radius.sm, + backgroundColor: step === i ? theme.colors.primary : step > i ? theme.colors.secondary : theme.colors.surface, + alignItems: "center", + }} + > + = i ? "#FFFFFF" : theme.colors.textSecondary, fontSize: 12, fontWeight: "600" }}> + {label} + + + ))} + + {/* Step 0: Category */} + {step === 0 && ( + + Изберете категорија + + {CATEGORIES.map((cat) => ( + setCategory(cat.slug)} + style={{ + flexDirection: "row", + alignItems: "center", + gap: theme.spacing.md, + padding: theme.spacing.md, + borderRadius: theme.radius.md, + borderWidth: 2, + borderColor: category === cat.slug ? theme.colors.primary : theme.colors.border, + backgroundColor: category === cat.slug ? theme.colors.primaryLight : theme.colors.card, + }} + > + {CATEGORY_EMOJI[cat.slug]} + + {cat.name} + + {category === cat.slug && ( + + )} + + ))} + + + )} + + {/* Step 1: Title + Description */} + {step === 1 && ( + + Детали за огласот + + + Наслов * + + + {title.length > 0 && title.length < 3 && ( + Насловот треба да има најмалку 3 знаци + )} + + + + Опис * + + + {description.length > 0 && description.length < 10 && ( + Описот треба да има најмалку 10 знаци + )} + + {category && } + + )} + + {/* Step 2: Location */} + {step === 2 && ( + + Локација + + + Адреса / град * + + + + + 📍 + Избор на локација на мапа — доаѓа наскоро + + + )} + + {/* Step 3: Price + Availability */} + {step === 3 && ( + + Цена и расположивост + + + Ценовен опсег + + + + + + Расположивост + + + + + + Слики + + + 📷 + Додавање слики — доаѓа наскоро + + + + {/* Summary */} + + Преглед + + Категорија: + {getCategoryName(category)} + + + Наслов: + {title || "—"} + + + Локација: + {location || "—"} + + + Цена: + {priceRange || "—"} + + + + + + )} + + {/* Navigation buttons */} + {step < 3 && ( + + {step > 0 && ( + + )} + + + )} + {step === 3 && step > 0 && ( + + )} ); diff --git a/convex/ads.ts b/convex/ads.ts index 87a79dd..e7a1c4e 100644 --- a/convex/ads.ts +++ b/convex/ads.ts @@ -1,4 +1,4 @@ -import { query } from "./_generated/server"; +import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; export const list = query({ @@ -59,4 +59,112 @@ export const getByHandyman = query({ .order("desc") .collect(); }, +}); + +export const create = mutation({ + args: { + token: v.string(), + title: v.string(), + description: v.string(), + category: v.string(), + location: v.string(), + lat: v.optional(v.number()), + lng: v.optional(v.number()), + priceRange: v.optional(v.string()), + availability: v.optional(v.string()), + imageIds: v.optional(v.array(v.string())), + }, + handler: async (ctx, args) => { + const session = await ctx.db + .query("sessions") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + if (!session) throw new Error("Неавторизиран"); + + const user = await ctx.db.get(session.userId); + if (!user) throw new Error("Корисникот не е пронајден"); + if (user.role !== "handyman") throw new Error("Само мајстори можат да креираат огласи"); + + const now = Date.now(); + const adId = await ctx.db.insert("ads", { + handymanId: session.userId, + title: args.title, + description: args.description, + category: args.category, + location: args.location, + lat: args.lat, + lng: args.lng, + priceRange: args.priceRange, + availability: args.availability, + imageIds: args.imageIds, + ratingAvg: undefined, + reviewCount: 0, + createdAt: now, + updatedAt: now, + }); + + return adId; + }, +}); + +export const update = mutation({ + args: { + token: v.string(), + adId: v.id("ads"), + title: v.optional(v.string()), + description: v.optional(v.string()), + category: v.optional(v.string()), + location: v.optional(v.string()), + lat: v.optional(v.number()), + lng: v.optional(v.number()), + priceRange: v.optional(v.string()), + availability: v.optional(v.string()), + imageIds: v.optional(v.array(v.string())), + }, + handler: async (ctx, args) => { + const session = await ctx.db + .query("sessions") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + if (!session) throw new Error("Неавторизиран"); + + const ad = await ctx.db.get(args.adId); + if (!ad) throw new Error("Огласот не е пронајден"); + if (ad.handymanId !== session.userId) throw new Error("Немате дозвола да го уредите овој оглас"); + + const updates: Record = { updatedAt: Date.now() }; + if (args.title !== undefined) updates.title = args.title; + if (args.description !== undefined) updates.description = args.description; + if (args.category !== undefined) updates.category = args.category; + if (args.location !== undefined) updates.location = args.location; + if (args.lat !== undefined) updates.lat = args.lat; + if (args.lng !== undefined) updates.lng = args.lng; + if (args.priceRange !== undefined) updates.priceRange = args.priceRange; + if (args.availability !== undefined) updates.availability = args.availability; + if (args.imageIds !== undefined) updates.imageIds = args.imageIds; + + await ctx.db.patch(args.adId, updates); + return args.adId; + }, +}); + +export const remove = mutation({ + args: { + token: v.string(), + adId: v.id("ads"), + }, + handler: async (ctx, args) => { + const session = await ctx.db + .query("sessions") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + if (!session) throw new Error("Неавторизиран"); + + const ad = await ctx.db.get(args.adId); + if (!ad) throw new Error("Огласот не е пронајден"); + if (ad.handymanId !== session.userId) throw new Error("Немате дозвола да го избришете овој оглас"); + + await ctx.db.delete(args.adId); + return true; + }, }); \ No newline at end of file