diff --git a/app/(tabs)/(posts)/index.tsx b/app/(tabs)/(posts)/index.tsx index 3aafd27..5bb6188 100644 --- a/app/(tabs)/(posts)/index.tsx +++ b/app/(tabs)/(posts)/index.tsx @@ -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("open"); + + const posts = useQuery( + api.posts.list, + activeTab === "open" ? { status: "open" } : {} + ); + + const isCustomer = currentUser?.role !== "handyman"; return ( - - - 📋 - - Побарувања - - - Објавете што ви треба и најдете мајстор кој ќе ви помогне. - + + {isAuthenticated && isCustomer && ( + router.push("/post/create" as any)}> + + + + Ново побарување + + + + )} + + + {TABS.map((tab) => ( + 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 } + : {}), + }} + > + + {tab.label} + + + ))} + + {posts === undefined ? ( + + ) : posts.length === 0 ? ( + + 📋 + + Нема побарувања + + + {activeTab === "open" + ? "Нема отворени побарувања во моментот." + : "Сеуште нема побарувања."} + + + ) : ( + + {posts.map((post) => ( + + ))} + + )} ); } \ No newline at end of file diff --git a/app/post/[id].tsx b/app/post/[id].tsx index 646d4a2..744f40c 100644 --- a/app/post/[id].tsx +++ b/app/post/[id].tsx @@ -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 ( + <> + + + + + + ); + } + + 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 ( <> - - - 📋 - - Побарување {id} - + + + + + {post.title} + + + + + + + {categoryName && ( + + Категорија: + + + )} + {post.location && ( + + 📍 Локација: + {post.location} + + )} + {post.budget && ( + + 💰 Буџет: + {post.budget} + + )} + + 🗓 Објавено: + {timeAgo} + + + + + + + {post.description} + + + + {isOwner && isOpen && ( + + + + )} + + {!isOwner && isHandyman && isOpen && isAuthenticated && ( + + + + )} ); +} + +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} месеци`; } \ No newline at end of file diff --git a/app/post/create.tsx b/app/post/create.tsx index 6abdff4..b79433d 100644 --- a/app/post/create.tsx +++ b/app/post/create.tsx @@ -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(null); + const [location, setLocation] = useState(""); + const [budget, setBudget] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const createPost = useMutation(api.posts.create); + + if (!isAuthenticated) { + return ( + <> + + + 🔒 + + Најавете се + + + Треба да сте најавени за да креирате побарување. + + + + + ); + } + + 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 ( <> - - - - Креирање побарување — доаѓа во Фаза 4 - + + + Наслов * + + + + Опис * + + + + + Категорија + + {CATEGORIES.map((cat) => ( + + setCategory(category === cat.slug ? null : cat.slug)} + > + {cat.name} + + + ))} + + + + + Локација + + + + + Буџет + + + + ); diff --git a/components/post-card.tsx b/components/post-card.tsx new file mode 100644 index 0000000..aeb56ba --- /dev/null +++ b/components/post-card.tsx @@ -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 ( + + + + + {post.title} + + + + + + {post.description} + + + + {categoryName && } + {post.location && ( + 📍 {post.location} + )} + {post.budget && ( + + 💰 {post.budget} + + )} + + + + ); +} \ No newline at end of file diff --git a/convex/chats.ts b/convex/chats.ts index fee7962..397d97e 100644 --- a/convex/chats.ts +++ b/convex/chats.ts @@ -1,4 +1,4 @@ -import { query } from "./_generated/server"; +import { query, mutation } from "./_generated/server"; import { v } from "convex/values"; export const listByUser = query({ @@ -7,4 +7,36 @@ export const listByUser = query({ const allChats = await ctx.db.query("chats").order("desc").collect(); 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; + }, }); \ No newline at end of file diff --git a/convex/posts.ts b/convex/posts.ts index f004169..c64ce9e 100644 --- a/convex/posts.ts +++ b/convex/posts.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({ @@ -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; + }, }); \ No newline at end of file