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 { 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={{
|
||||||
|
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>
|
</Text>
|
||||||
</View>
|
</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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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>
|
</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>
|
</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 { 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
|
||||||
|
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>
|
</Text>
|
||||||
</View>
|
</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
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";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export const listByUser = query({
|
export const listByUser = query({
|
||||||
@ -8,3 +8,35 @@ export const listByUser = query({
|
|||||||
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user