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:
echo 2026-05-29 18:31:08 +02:00
parent ff464b0f9b
commit c85ea09d4b
6 changed files with 545 additions and 27 deletions

View File

@ -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 { 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() { export default function PostsScreen() {
const router = useRouter();
const theme = useTheme(); 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 ( return (
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg }}> <ScrollView
<View style={{ alignItems: "center", justifyContent: "center", paddingVertical: 60 }}> contentInsetAdjustmentBehavior="automatic"
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text> contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}> >
Побарувања {isAuthenticated && isCustomer && (
</Text> <Pressable onPress={() => router.push("/post/create" as any)}>
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}> <View
Објавете што ви треба и најдете мајстор кој ќе ви помогне. style={{
</Text> 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> </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> </ScrollView>
); );
} }

View File

@ -1,22 +1,155 @@
import { View, Text, ScrollView } from "react-native"; import { View, Text, ScrollView, 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 { 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() { export default function PostDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const theme = useTheme(); 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 ( return (
<> <>
<Stack.Screen options={{ title: "Побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} /> <Stack.Screen options={{ title: "Побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}> <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}> <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text> <View style={{ flex: 1 }}>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}> <Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
Побарување {id} {post.title}
</Text> </Text>
</View>
<Badge label={isOpen ? "Отворено" : "Затворено"} variant={isOpen ? "success" : "error"} />
</View> </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> </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} месеци`;
} }

View File

@ -1,19 +1,155 @@
import { View, Text, ScrollView } from "react-native"; import { View, Text, ScrollView, Alert } from "react-native";
import { Stack } from "expo-router"; 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 { 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() { export default function CreatePostScreen() {
const router = useRouter();
const theme = useTheme(); 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 ( return (
<> <>
<Stack.Screen options={{ title: "Ново побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} /> <Stack.Screen options={{ title: "Ново побарување", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}> <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}> <View style={{ gap: theme.spacing.xs }}>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}> <Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>Наслов *</Text>
Креирање побарување доаѓа во Фаза 4 <Input
</Text> placeholder="Што ви треба?"
value={title}
onChangeText={setTitle}
maxLength={100}
/>
</View> </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> </ScrollView>
</> </>
); );

62
components/post-card.tsx Normal file
View 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>
);
}

View File

@ -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 listByUser = query({ export const listByUser = query({
@ -7,4 +7,36 @@ export const listByUser = query({
const allChats = await ctx.db.query("chats").order("desc").collect(); const allChats = await ctx.db.query("chats").order("desc").collect();
return allChats.filter((chat) => chat.participantIds.includes(args.userId)); 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;
},
}); });

View File

@ -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({
@ -21,6 +21,71 @@ export const getByCustomer = query({
return await ctx.db return await ctx.db
.query("posts") .query("posts")
.withIndex("by_customer", (q) => q.eq("customerId", args.customerId)) .withIndex("by_customer", (q) => q.eq("customerId", args.customerId))
.order("desc")
.collect(); .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;
},
}); });