feat: Phase 4 - customer posts feed, create, detail, respond
- Add create/close post mutations with auth & ownership verification - Add getOrCreate chat mutation for handyman response flow - PostCard component: title, description preview, category badge, location, budget, status badge (open=green, closed=red) - Post detail screen: full description, category, location, budget, status, time-ago. Owner: close button. Handyman: respond button creates/opens chat with customer - Create post screen: title, description, category picker, location, budget, auth-gated with redirect - Posts feed: open/all filter tabs, new-post button for customers All UI text in Macedonian. TypeScript clean, Convex functions deployed.
This commit is contained in:
parent
ff464b0f9b
commit
c85ea09d4b
@ -1,20 +1,110 @@
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { View, Text, ScrollView, Pressable } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { useAuth } from "../../_layout";
|
||||
import { useTheme } from "../../../components/theme";
|
||||
import { PostCard } from "../../../components/post-card";
|
||||
import { Loading } from "../../../components/ui/loading";
|
||||
|
||||
const TABS = [
|
||||
{ key: "open", label: "Отворени" },
|
||||
{ key: "all", label: "Сите" },
|
||||
] as const;
|
||||
|
||||
type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
export default function PostsScreen() {
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const { token, isAuthenticated } = useAuth();
|
||||
const currentUser = useQuery(
|
||||
api.users.getCurrentUser,
|
||||
isAuthenticated && token ? { token } : "skip"
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("open");
|
||||
|
||||
const posts = useQuery(
|
||||
api.posts.list,
|
||||
activeTab === "open" ? { status: "open" } : {}
|
||||
);
|
||||
|
||||
const isCustomer = currentUser?.role !== "handyman";
|
||||
|
||||
return (
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg }}>
|
||||
<View style={{ alignItems: "center", justifyContent: "center", paddingVertical: 60 }}>
|
||||
<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" }}>
|
||||
Објавете што ви треба и најдете мајстор кој ќе ви помогне.
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
||||
>
|
||||
{isAuthenticated && isCustomer && (
|
||||
<Pressable onPress={() => router.push("/post/create" as any)}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: theme.spacing.lg,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 16 }}>
|
||||
+ Ново побарување
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<View style={{ flexDirection: "row", backgroundColor: theme.colors.surface, borderRadius: theme.radius.md, padding: 4 }}>
|
||||
{TABS.map((tab) => (
|
||||
<Pressable
|
||||
key={tab.key}
|
||||
onPress={() => setActiveTab(tab.key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: theme.radius.sm,
|
||||
alignItems: "center",
|
||||
backgroundColor: activeTab === tab.key ? theme.colors.card : "transparent",
|
||||
...(activeTab === tab.key
|
||||
? { boxShadow: "0 1px 3px rgba(0,0,0,0.08)" as any }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: activeTab === tab.key ? "600" : "400",
|
||||
fontSize: 14,
|
||||
color: activeTab === tab.key ? theme.colors.text : theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{posts === undefined ? (
|
||||
<Loading />
|
||||
) : posts.length === 0 ? (
|
||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||
<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" }}>
|
||||
{activeTab === "open"
|
||||
? "Нема отворени побарувања во моментот."
|
||||
: "Сеуште нема побарувања."}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.md }}>
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post._id} post={post} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@ -1,22 +1,155 @@
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { useLocalSearchParams, Stack } from "expo-router";
|
||||
import { View, Text, ScrollView, 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 { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { Loading } from "../../components/ui/loading";
|
||||
import { Avatar } from "../../components/ui/avatar";
|
||||
import { getCategoryName } from "../../lib/constants";
|
||||
|
||||
export default function PostDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const { token, userId, isAuthenticated } = useAuth();
|
||||
|
||||
const post = useQuery(api.posts.getById, { id: id as any });
|
||||
const currentUser = useQuery(
|
||||
api.users.getCurrentUser,
|
||||
isAuthenticated && token ? { token } : "skip"
|
||||
);
|
||||
|
||||
const closePost = useMutation(api.posts.close);
|
||||
const getOrCreateChat = useMutation(api.chats.getOrCreate);
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: theme.colors.background }}>
|
||||
<Loading />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const customerId = post.customerId as string;
|
||||
const isOwner = isAuthenticated && userId && customerId === userId;
|
||||
const isHandyman = currentUser?.role === "handyman";
|
||||
const isOpen = post.status === "open";
|
||||
|
||||
async function handleClose() {
|
||||
Alert.alert(
|
||||
"Затвори побарување",
|
||||
"Дали сте сигурни дека сакате да го затворите оваа побарување?",
|
||||
[
|
||||
{ text: "Откажи", style: "cancel" },
|
||||
{
|
||||
text: "Затвори",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await closePost({ token: token!, postId: id as any });
|
||||
} catch (e: any) {
|
||||
Alert.alert("Грешка", e.message || "Неуспешно затворање");
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRespond() {
|
||||
try {
|
||||
const chatId = await getOrCreateChat({ token: token!, partnerId: post!.customerId });
|
||||
router.push(`/chat/${chatId}` as any);
|
||||
} catch (e: any) {
|
||||
Alert.alert("Грешка", e.message || "Неуспешно поврзување");
|
||||
}
|
||||
}
|
||||
|
||||
const categoryName = post.category ? getCategoryName(post.category) : null;
|
||||
const timeAgo = getTimeAgo(post.createdAt);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
|
||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
|
||||
Побарување {id}
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
|
||||
{post.title}
|
||||
</Text>
|
||||
</View>
|
||||
<Badge label={isOpen ? "Отворено" : "Затворено"} variant={isOpen ? "success" : "error"} />
|
||||
</View>
|
||||
|
||||
<Card>
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
{categoryName && (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>Категорија:</Text>
|
||||
<Badge label={categoryName} variant="primary" />
|
||||
</View>
|
||||
)}
|
||||
{post.location && (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 Локација:</Text>
|
||||
<Text style={{ fontSize: 14, color: theme.colors.text }}>{post.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{post.budget && (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>💰 Буџет:</Text>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary }}>{post.budget}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>🗓 Објавено:</Text>
|
||||
<Text style={{ fontSize: 14, color: theme.colors.text }}>{timeAgo}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text style={{ fontSize: 16, color: theme.colors.text, lineHeight: 24 }}>
|
||||
{post.description}
|
||||
</Text>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
|
||||
{isOwner && isOpen && (
|
||||
<View style={{ position: "absolute", bottom: 0, left: 0, right: 0, padding: theme.spacing.lg, backgroundColor: theme.colors.background }}>
|
||||
<Button variant="destructive" onPress={handleClose}>
|
||||
Затвори оглас
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isOwner && isHandyman && isOpen && isAuthenticated && (
|
||||
<View style={{ position: "absolute", bottom: 0, left: 0, right: 0, padding: theme.spacing.lg, backgroundColor: theme.colors.background }}>
|
||||
<Button onPress={handleRespond}>
|
||||
Одговори
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return "Пред неколку секунди";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `Пред ${minutes} мин`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `Пред ${hours} ч`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `Пред ${days} ден`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `Пред ${months} месеци`;
|
||||
}
|
||||
@ -1,19 +1,155 @@
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { Stack } from "expo-router";
|
||||
import { View, Text, ScrollView, Alert } from "react-native";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { 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 { Card } from "../../components/ui/card";
|
||||
import { CATEGORIES } from "../../lib/constants";
|
||||
|
||||
export default function CreatePostScreen() {
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
const { token, isAuthenticated } = useAuth();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [location, setLocation] = useState("");
|
||||
const [budget, setBudget] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const createPost = useMutation(api.posts.create);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Ново побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "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" as any)}>Најави се</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title.trim()) {
|
||||
Alert.alert("Грешка", "Внесете наслов");
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
Alert.alert("Грешка", "Внесете опис");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const postId = await createPost({
|
||||
token: token!,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
category: category ?? undefined,
|
||||
location: location.trim() || undefined,
|
||||
budget: budget.trim() || undefined,
|
||||
});
|
||||
router.replace(`/post/${postId}` as any);
|
||||
} catch (e: any) {
|
||||
Alert.alert("Грешка", e.message || "Неуспешно креирање");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Ново побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
|
||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
|
||||
Креирање побарување — доаѓа во Фаза 4
|
||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}>
|
||||
<View style={{ gap: theme.spacing.xs }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>Наслов *</Text>
|
||||
<Input
|
||||
placeholder="Што ви треба?"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
maxLength={100}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: theme.spacing.xs }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>Опис *</Text>
|
||||
<Input
|
||||
placeholder="Опишете го проблемот детално..."
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
style={{ minHeight: 100, textAlignVertical: "top" }}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: theme.spacing.xs }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>Категорија</Text>
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.xs }}>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<View
|
||||
key={cat.slug}
|
||||
style={{
|
||||
backgroundColor: category === cat.slug ? theme.colors.primary : theme.colors.surface,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: category === cat.slug ? theme.colors.primary : theme.colors.border,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: category === cat.slug ? "#FFFFFF" : theme.colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: "500",
|
||||
}}
|
||||
onPress={() => setCategory(category === cat.slug ? null : cat.slug)}
|
||||
>
|
||||
{cat.name}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: theme.spacing.xs }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>Локација</Text>
|
||||
<Input
|
||||
placeholder="нр. Скопје, Битола..."
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
maxLength={100}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: theme.spacing.xs }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>Буџет</Text>
|
||||
<Input
|
||||
placeholder="нр. 5000 - 10000 денари"
|
||||
value={budget}
|
||||
onChangeText={setBudget}
|
||||
maxLength={100}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button onPress={handleSubmit} loading={submitting} disabled={submitting}>
|
||||
Објави побарување
|
||||
</Button>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
|
||||
62
components/post-card.tsx
Normal file
62
components/post-card.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { Link } from "expo-router";
|
||||
import { useTheme } from "./theme";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { getCategoryName } from "../lib/constants";
|
||||
|
||||
interface PostCardProps {
|
||||
post: {
|
||||
_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
location?: string;
|
||||
budget?: string;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function PostCard({ post }: PostCardProps) {
|
||||
const theme = useTheme();
|
||||
const categoryName = post.category ? getCategoryName(post.category) : null;
|
||||
const isOpen = post.status === "open";
|
||||
|
||||
return (
|
||||
<Link href={`/post/${post._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 }} numberOfLines={2}>
|
||||
{post.title}
|
||||
</Text>
|
||||
<Badge label={isOpen ? "Отворено" : "Затворено"} variant={isOpen ? "success" : "error"} />
|
||||
</View>
|
||||
|
||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }} numberOfLines={2}>
|
||||
{post.description}
|
||||
</Text>
|
||||
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
{categoryName && <Badge label={categoryName} variant="primary" />}
|
||||
{post.location && (
|
||||
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {post.location}</Text>
|
||||
)}
|
||||
{post.budget && (
|
||||
<Text style={{ fontSize: 13, color: theme.colors.primary, fontWeight: "600" }}>
|
||||
💰 {post.budget}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const listByUser = query({
|
||||
@ -8,3 +8,35 @@ export const listByUser = query({
|
||||
return allChats.filter((chat) => chat.participantIds.includes(args.userId));
|
||||
},
|
||||
});
|
||||
|
||||
export const getOrCreate = mutation({
|
||||
args: {
|
||||
token: v.string(),
|
||||
partnerId: v.id("users"),
|
||||
},
|
||||
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 allChats = await ctx.db.query("chats").collect();
|
||||
const existing = allChats.find(
|
||||
(c) =>
|
||||
c.participantIds.includes(session.userId) &&
|
||||
c.participantIds.includes(args.partnerId) &&
|
||||
c.participantIds.length === 2
|
||||
);
|
||||
|
||||
if (existing) return existing._id;
|
||||
|
||||
const chatId = await ctx.db.insert("chats", {
|
||||
participantIds: [session.userId, args.partnerId],
|
||||
lastMessageAt: Date.now(),
|
||||
createdBy: session.userId,
|
||||
});
|
||||
|
||||
return chatId;
|
||||
},
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const list = query({
|
||||
@ -21,6 +21,71 @@ export const getByCustomer = query({
|
||||
return await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_customer", (q) => q.eq("customerId", args.customerId))
|
||||
.order("desc")
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const getById = query({
|
||||
args: { id: v.id("posts") },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.get(args.id);
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
token: v.string(),
|
||||
title: v.string(),
|
||||
description: v.string(),
|
||||
category: v.optional(v.string()),
|
||||
location: v.optional(v.string()),
|
||||
budget: v.optional(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("Корисникот не е пронајден");
|
||||
|
||||
const now = Date.now();
|
||||
const postId = await ctx.db.insert("posts", {
|
||||
customerId: session.userId,
|
||||
title: args.title,
|
||||
description: args.description,
|
||||
category: args.category,
|
||||
location: args.location,
|
||||
budget: args.budget,
|
||||
status: "open",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
return postId;
|
||||
},
|
||||
});
|
||||
|
||||
export const close = mutation({
|
||||
args: {
|
||||
token: v.string(),
|
||||
postId: v.id("posts"),
|
||||
},
|
||||
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 post = await ctx.db.get(args.postId);
|
||||
if (!post) throw new Error("Побарувањето не е пронајдено");
|
||||
if (post.customerId !== session.userId) throw new Error("Само авторот може да го затвори побарувањето");
|
||||
|
||||
await ctx.db.patch(args.postId, { status: "closed", updatedAt: Date.now() });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user