- Error boundary with Macedonian fallback screen and retry button wrapping all navigation - Pull-to-refresh on all list/detail screens (home, explore, posts, chat, profile, ad detail, post detail) - Accessibility: labels on all interactive elements, roles on Pressable/Button, selectable text for user data - Deep linking: mojmajstor:// scheme configured in app.json, ad/[id] and chat/[id] routes work via Expo Router auto-routing - Pagination: cursor-based paginated queries for ads, posts, messages, chats, reviews with Load More buttons on frontend - Profile: shows customer posts with PostCard, loading/error states, create-post navigation - Auth error messages in Macedonian (Невалидни акредитиви, итн.) All UI text in Macedonian. TypeScript clean, Convex functions deployed.
385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
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")} accessibilityLabel="Најави се">Најави се</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}
|
||
accessible
|
||
accessibilityLabel={`Чекор ${label}`}
|
||
accessibilityRole="button"
|
||
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}
|
||
accessible
|
||
accessibilityLabel={cat.name}
|
||
accessibilityRole="button"
|
||
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}
|
||
accessibilityLabel="Наслов на оглас"
|
||
/>
|
||
{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}
|
||
accessibilityLabel="Опис на оглас"
|
||
/>
|
||
{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}
|
||
accessibilityLabel="Локација"
|
||
/>
|
||
</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}
|
||
accessibilityLabel="Ценовен опсег"
|
||
/>
|
||
</View>
|
||
<View>
|
||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||
Расположивост
|
||
</Text>
|
||
<Input
|
||
placeholder="Пр: Достапен Петоци - Недели"
|
||
value={availability}
|
||
onChangeText={setAvailability}
|
||
accessibilityLabel="Расположивост"
|
||
/>
|
||
</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>
|
||
</>
|
||
);
|
||
} |