feat: Phase 3 - ad creation, ad detail, profile my-ads
- 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.
This commit is contained in:
parent
ce7e400720
commit
ff464b0f9b
@ -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 { useRouter } from "expo-router";
|
||||||
import { useQuery, useMutation } from "convex/react";
|
import { useQuery, useMutation } from "convex/react";
|
||||||
import { api } from "../../../convex/_generated/api";
|
import { api } from "../../../convex/_generated/api";
|
||||||
import { useAuth } from "../../_layout";
|
import { useAuth } from "../../_layout";
|
||||||
import { useTheme } from "../../../components/theme";
|
import { useTheme } from "../../../components/theme";
|
||||||
import { Button } from "../../../components/ui/button";
|
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() {
|
export default function ProfileScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const theme = useTheme();
|
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 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);
|
const logoutMutation = useMutation(api.auth.logout);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
@ -23,6 +35,27 @@ export default function ProfileScreen() {
|
|||||||
router.replace("/(auth)/login");
|
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) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
|
||||||
@ -38,33 +71,132 @@ export default function ProfileScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: theme.colors.background }}>
|
||||||
|
<Loading />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHandyman = user.role === "handyman";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}>
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
||||||
|
>
|
||||||
|
{/* Profile header */}
|
||||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||||||
<View style={{
|
<Avatar uri={user.avatarId ?? null} name={user.name || "Корисник"} size={80} />
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
borderRadius: 40,
|
|
||||||
backgroundColor: theme.colors.primaryLight,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}>
|
|
||||||
<Text style={{ color: theme.colors.primary, fontWeight: "700", fontSize: 28 }}>
|
|
||||||
{user?.name?.charAt(0)?.toUpperCase() || "?"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginTop: theme.spacing.md }}>
|
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginTop: theme.spacing.md }}>
|
||||||
{user?.name || "Корисник"}
|
{user.name || "Корисник"}
|
||||||
</Text>
|
|
||||||
<Text style={{ color: theme.colors.textSecondary }}>
|
|
||||||
{user?.role === "handyman" ? "Мајстор" : "Клиент"}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Badge label={isHandyman ? "Мајстор" : "Клиент"} variant={isHandyman ? "primary" : "default"} />
|
||||||
|
{user.email && (
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, fontSize: 14, marginTop: theme.spacing.xs }}>
|
||||||
|
{user.email}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{user.phone && (
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
|
||||||
|
{user.phone}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ backgroundColor: theme.colors.card, borderRadius: theme.radius.lg, padding: theme.spacing.md, gap: theme.spacing.sm }}>
|
{/* Handyman sections */}
|
||||||
<Pressable style={{ paddingVertical: theme.spacing.sm }}>
|
{isHandyman && (
|
||||||
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Мои огласи</Text>
|
<>
|
||||||
</Pressable>
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||||||
|
Мои огласи
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => router.push("/ad/create" as any)}>
|
||||||
|
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
|
||||||
|
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Нов оглас</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{myAds === undefined ? (
|
||||||
|
<Loading />
|
||||||
|
) : myAds.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||||||
|
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📋</Text>
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
|
||||||
|
Сеуште немате огласи. Креирајте нов оглас за да се прикажете на мајсторите.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
|
{myAds.map((ad) => (
|
||||||
|
<View key={ad._id} style={{ gap: theme.spacing.sm }}>
|
||||||
|
<AdCard ad={ad} />
|
||||||
|
<View style={{ flexDirection: "row", gap: theme.spacing.sm }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Уреди</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => handleDeleteAd(ad._id)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.errorLight,
|
||||||
|
borderRadius: theme.radius.md,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.colors.error, fontWeight: "600", fontSize: 14 }}>Избриши</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Customer sections */}
|
||||||
|
{!isHandyman && (
|
||||||
|
<>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||||||
|
Мои побарувања
|
||||||
|
</Text>
|
||||||
|
<Pressable>
|
||||||
|
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
|
||||||
|
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Ново побарување</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||||||
|
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📝</Text>
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
|
||||||
|
Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Menu items */}
|
||||||
|
<View style={{ backgroundColor: theme.colors.card, borderRadius: theme.radius.lg, padding: theme.spacing.md, gap: theme.spacing.sm, borderWidth: 1, borderColor: theme.colors.border }}>
|
||||||
<Pressable style={{ paddingVertical: theme.spacing.sm }}>
|
<Pressable style={{ paddingVertical: theme.spacing.sm }}>
|
||||||
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Оцени</Text>
|
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Оцени</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
185
app/ad/[id].tsx
185
app/ad/[id].tsx
@ -1,21 +1,190 @@
|
|||||||
import { View, Text, ScrollView } from "react-native";
|
import { View, Text, ScrollView, Pressable, Alert } from "react-native";
|
||||||
import { useLocalSearchParams, Stack } from "expo-router";
|
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 { 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() {
|
export default function AdDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const theme = useTheme();
|
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 (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: theme.colors.background }}>
|
||||||
|
<Loading />
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
<Stack.Screen
|
||||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
|
options={{
|
||||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
title: ad.title.length > 20 ? ad.title.slice(0, 20) + "…" : ad.title,
|
||||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
|
headerShown: true,
|
||||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
|
headerBackButtonDisplayMode: "minimal",
|
||||||
Оглас {id}
|
}}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}
|
||||||
|
>
|
||||||
|
{/* Gallery placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.radius.lg,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderStyle: "dashed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 48 }}>{categoryEmoji}</Text>
|
||||||
|
<Text style={{ color: theme.colors.textTertiary, marginTop: theme.spacing.sm, fontSize: 14 }}>
|
||||||
|
Нема слики
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Title + Price */}
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
|
||||||
|
{ad.priceRange && (
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.primary, marginTop: theme.spacing.xs }}>
|
||||||
|
{ad.priceRange}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Category + Location */}
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
|
||||||
|
<Badge label={categoryName} variant="primary" />
|
||||||
|
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 {ad.location}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
||||||
|
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={20} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Availability */}
|
||||||
|
{ad.availability && (
|
||||||
|
<Card>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
|
Расположивост
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>{ad.availability}</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Card>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
|
Опис
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 15, color: theme.colors.textSecondary, lineHeight: 22 }}>{ad.description}</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Handyman info */}
|
||||||
|
<Card>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
if (handyman) router.push(`/(tabs)/(profile)`);
|
||||||
|
}}
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.md }}
|
||||||
|
>
|
||||||
|
<Avatar uri={handyman?.avatarId ?? null} name={handyman?.name || "Мајстор"} size={48} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
|
||||||
|
{handyman?.name || "Мајстор"}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 13, color: theme.colors.textSecondary }}>Мајстор</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{!isOwner && isAuthenticated && (
|
||||||
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
|
<Button onPress={() => {}}>Започни разговор</Button>
|
||||||
|
<Button variant="outline" onPress={() => {}}>Напиши оценка</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Owner actions */}
|
||||||
|
{isOwner && (
|
||||||
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onPress={() => router.push(`/ad/create?editId=${ad._id}` as any)}
|
||||||
|
>
|
||||||
|
Уреди оглас
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onPress={handleDelete}>
|
||||||
|
Избриши оглас
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOwner && !isAuthenticated && (
|
||||||
|
<Card>
|
||||||
|
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||||
|
Најавете се за да започнете разговор или да напишете оценка.
|
||||||
|
</Text>
|
||||||
|
<View style={{ marginTop: theme.spacing.md }}>
|
||||||
|
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,19 +1,373 @@
|
|||||||
import { View, Text, ScrollView } from "react-native";
|
import { View, Text, ScrollView, Pressable, Alert } from "react-native";
|
||||||
import { Stack } from "expo-router";
|
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 { 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() {
|
export default function CreateAdScreen() {
|
||||||
const theme = useTheme();
|
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<string>("");
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Нов оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||||
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
|
||||||
|
<Text style={{ fontSize: 48, marginBottom: 16 }}>🔒</Text>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
||||||
|
Најава потребна
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
|
||||||
|
Треба да бидете најавени за да креирате оглас.
|
||||||
|
</Text>
|
||||||
|
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && !isHandyman) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Нов оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||||
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
|
||||||
|
<Text style={{ fontSize: 48, marginBottom: 16 }}>🚫</Text>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
||||||
|
Само за мајстори
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||||
|
Само мајстори можат да креираат огласи.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user === undefined) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Нов оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<Loading />
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Нов оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
<Stack.Screen
|
||||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
|
options={{ title: isEditing ? "Уреди оглас" : "Нов оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }}
|
||||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
/>
|
||||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
|
<ScrollView
|
||||||
Креирање оглас — доаѓа во Фаза 3
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
</Text>
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Step indicator */}
|
||||||
|
<View style={{ flexDirection: "row", gap: theme.spacing.xs, marginBottom: theme.spacing.sm }}>
|
||||||
|
{["Категорија", "Детали", "Локација", "Цена"].map((label, i) => (
|
||||||
|
<Pressable
|
||||||
|
key={i}
|
||||||
|
onPress={() => 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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: step >= i ? "#FFFFFF" : theme.colors.textSecondary, fontSize: 12, fontWeight: "600" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Step 0: Category */}
|
||||||
|
{step === 0 && (
|
||||||
|
<View style={{ gap: theme.spacing.md }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>Изберете категорија</Text>
|
||||||
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<Pressable
|
||||||
|
key={cat.slug}
|
||||||
|
onPress={() => 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 24 }}>{CATEGORY_EMOJI[cat.slug]}</Text>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: "500", color: theme.colors.text, flex: 1 }}>
|
||||||
|
{cat.name}
|
||||||
|
</Text>
|
||||||
|
{category === cat.slug && (
|
||||||
|
<Text style={{ color: theme.colors.primary, fontWeight: "600" }}>✓</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 1: Title + Description */}
|
||||||
|
{step === 1 && (
|
||||||
|
<View style={{ gap: theme.spacing.md }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>Детали за огласот</Text>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
|
Наслов *
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="Пр: Водоинсталација на бања"
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
{title.length > 0 && title.length < 3 && (
|
||||||
|
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 4 }}>Насловот треба да има најмалку 3 знаци</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
|
Опис *
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="Опишете ги вашите услуги..."
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
multiline
|
||||||
|
numberOfLines={5}
|
||||||
|
style={{ minHeight: 120, textAlignVertical: "top" }}
|
||||||
|
maxLength={2000}
|
||||||
|
/>
|
||||||
|
{description.length > 0 && description.length < 10 && (
|
||||||
|
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 4 }}>Описот треба да има најмалку 10 знаци</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{category && <Badge label={getCategoryName(category)} variant="primary" />}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Location */}
|
||||||
|
{step === 2 && (
|
||||||
|
<View style={{ gap: theme.spacing.md }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>Локација</Text>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
|
Адреса / град *
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="Пр: Скопје, Чаир"
|
||||||
|
value={location}
|
||||||
|
onChangeText={setLocation}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.radius.lg,
|
||||||
|
padding: theme.spacing.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderStyle: "dashed",
|
||||||
|
height: 120,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 32, marginBottom: theme.spacing.xs }}>📍</Text>
|
||||||
|
<Text style={{ color: theme.colors.textTertiary, fontSize: 14 }}>Избор на локација на мапа — доаѓа наскоро</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Price + Availability */}
|
||||||
|
{step === 3 && (
|
||||||
|
<View style={{ gap: theme.spacing.md }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>Цена и расположивост</Text>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
|
Ценовен опсег
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="Пр: 500-1500 ден/час"
|
||||||
|
value={priceRange}
|
||||||
|
onChangeText={setPriceRange}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
|
Расположивост
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
placeholder="Пр: Достапен Петоци - Недели"
|
||||||
|
value={availability}
|
||||||
|
onChangeText={setAvailability}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
|
Слики
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: theme.radius.lg,
|
||||||
|
padding: theme.spacing.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderStyle: "dashed",
|
||||||
|
height: 120,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 32, marginBottom: theme.spacing.xs }}>📷</Text>
|
||||||
|
<Text style={{ color: theme.colors.textTertiary, fontSize: 14 }}>Додавање слики — доаѓа наскоро</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<View style={{ backgroundColor: theme.colors.surface, borderRadius: theme.radius.lg, padding: theme.spacing.md, gap: theme.spacing.sm }}>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>Преглед</Text>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, fontSize: 14 }}>Категорија:</Text>
|
||||||
|
<Text style={{ color: theme.colors.text, fontSize: 14 }}>{getCategoryName(category)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, fontSize: 14 }}>Наслов:</Text>
|
||||||
|
<Text style={{ color: theme.colors.text, fontSize: 14 }} numberOfLines={1}>{title || "—"}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, fontSize: 14 }}>Локација:</Text>
|
||||||
|
<Text style={{ color: theme.colors.text, fontSize: 14 }}>{location || "—"}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
||||||
|
<Text style={{ color: theme.colors.textSecondary, fontSize: 14 }}>Цена:</Text>
|
||||||
|
<Text style={{ color: theme.colors.text, fontSize: 14 }}>{priceRange || "—"}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={handleSubmit}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={!title.trim() || !description.trim() || !category || !location.trim()}
|
||||||
|
>
|
||||||
|
{isEditing ? "Зачувај промени" : "Објави оглас"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation buttons */}
|
||||||
|
{step < 3 && (
|
||||||
|
<View style={{ flexDirection: "row", gap: theme.spacing.sm }}>
|
||||||
|
{step > 0 && (
|
||||||
|
<Button variant="outline" onPress={() => setStep(step - 1)} style={{ flex: 1 }}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onPress={() => setStep(step + 1)}
|
||||||
|
disabled={!canProceed}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
Следно
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{step === 3 && step > 0 && (
|
||||||
|
<Button variant="outline" onPress={() => setStep(step - 1)}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
110
convex/ads.ts
110
convex/ads.ts
@ -1,4 +1,4 @@
|
|||||||
import { query } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
@ -60,3 +60,111 @@ export const getByHandyman = query({
|
|||||||
.collect();
|
.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<string, any> = { 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user