mojmajstor/app/ad/create.tsx
echo ff464b0f9b 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.
2026-05-29 18:28:01 +02:00

374 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { View, Text, ScrollView, Pressable, Alert } from "react-native";
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 { 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() {
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 (
<>
<Stack.Screen
options={{ title: isEditing ? "Уреди оглас" : "Нов оглас", headerShown: true, headerBackButtonDisplayMode: "minimal" }}
/>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
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>
{/* 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>
</>
);
}