checkpoint
This commit is contained in:
parent
8662037776
commit
e8f816916d
17
AGENTS.md
17
AGENTS.md
@ -7,7 +7,7 @@
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend:** Expo SDK 56, React Native 0.85, TypeScript, Expo Router (file-based routing), inline styles (no NativeWind/Tailwind)
|
||||
- **Backend:** Convex (self-deployed at `https://backend-onhwnh06p0g3976vl34yx18v.testbed.mk:3210`)
|
||||
- **Backend:** Convex (cloud at `https://decisive-owl-34.eu-west-1.convex.cloud`)
|
||||
- **Auth:** Custom email/password with SHA-256 (Convex actions), stored in `accounts`/`sessions` tables — no @convex-dev/auth (incompatible with self-hosted)
|
||||
- **Deployment:** EAS Build + Update
|
||||
|
||||
@ -18,7 +18,17 @@
|
||||
- Self-deployed Convex does **not** support `@convex-dev/auth` — custom auth via `convex/auth.ts`
|
||||
- Auth env vars already set: `AUTH_SECRET`, `AUTH_SECRET_1`-`AUTH_SECRET_10`, `AUTH_URL`
|
||||
|
||||
## Key Conventions
|
||||
## Design System
|
||||
|
||||
- **Colors:** Warm terracotta (`#D4764A`) + deep navy (`#1A2A3A`), off-white bg (`#FDF8F3`)
|
||||
- **Typography:** System font, hierarchy via `theme.typography` (h1/h2/h3/body/caption/label)
|
||||
- **Shadows:** Defined in `theme.shadows` as `{ shadowColor, shadowOffset, shadowOpacity, shadowRadius, elevation }`
|
||||
- **Theme context** via `ThemeProvider` + `useTheme()` from `components/theme.tsx`
|
||||
- **Spacing:** 4/8/16/24/32/48 via `theme.spacing`, radius via `theme.radius`
|
||||
- **Icons:** `@expo/vector-icons` (Ionicons) for tab bar; emoji for categories
|
||||
- **Styling:** Inline styles only, no NativeWind, no StyleSheet.create
|
||||
- **Card variants:** `default` (border), `elevated` (shadow), `outlined` (transparent bg + border)
|
||||
- **Button variants:** `primary` (terracotta + shadow), `secondary` (surface + border), `outline`, `ghost`, `destructive`
|
||||
|
||||
- **Language split:** Macedonian for all user-facing text, English for code
|
||||
- **Role system:** `handyman` | `customer` — set at registration. Handymen create ads; customers create posts; both can chat
|
||||
@ -56,8 +66,9 @@ app/
|
||||
|
||||
```bash
|
||||
npx expo start # Dev server
|
||||
npx expo start --dev-client # Dev server with dev client
|
||||
npm run convex:dev # Convex backend dev
|
||||
CONVEX_DEPLOYMENT="" npx convex dev --url $URL --once # Deploy once
|
||||
npx convex deploy # Deploy to cloud
|
||||
npx tsc --noEmit # Type check
|
||||
```
|
||||
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@ -1 +1,15 @@
|
||||
@AGENTS.md
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read
|
||||
`convex/_generated/ai/guidelines.md` first** for important guidelines on
|
||||
how to correctly use Convex APIs and patterns. The file contains rules that
|
||||
override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running
|
||||
`npx convex ai-files install`.
|
||||
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
@ -51,55 +51,85 @@ export default function LoginScreen() {
|
||||
style={{ flex: 1, backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
||||
contentContainerStyle={{ flexGrow: 1, justifyContent: "center", padding: theme.spacing.lg }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={{ marginTop: 60, marginBottom: theme.spacing.xl }}>
|
||||
<Text style={{ fontSize: 32, fontWeight: "700", color: theme.colors.text }}>
|
||||
МојМајстор
|
||||
</Text>
|
||||
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
|
||||
Најдете мајстор во вашата близина
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.radius.xl,
|
||||
padding: theme.spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderLight,
|
||||
...theme.shadows.md,
|
||||
gap: theme.spacing.md,
|
||||
}}
|
||||
>
|
||||
<View style={{ alignItems: "center", marginBottom: theme.spacing.sm }}>
|
||||
<View
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: theme.colors.primaryLight,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: theme.spacing.md,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 36 }}>🔧</Text>
|
||||
</View>
|
||||
<Text style={{ ...theme.typography.h1, color: theme.colors.text }}>
|
||||
МојМајстор
|
||||
</Text>
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
|
||||
Најдете мајстор во вашата близина
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
placeholder="Е-пошта"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
accessibilityLabel="Е-пошта"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Е-пошта"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
accessibilityLabel="Е-пошта"
|
||||
label="Е-пошта"
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Лозинка"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
accessibilityLabel="Лозинка"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Лозинка"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
accessibilityLabel="Лозинка"
|
||||
label="Лозинка"
|
||||
/>
|
||||
|
||||
<View style={{ marginTop: theme.spacing.sm }}>
|
||||
<Button loading={loading} onPress={handleLogin} accessibilityLabel="Најави се">
|
||||
<Button
|
||||
loading={loading}
|
||||
onPress={handleLogin}
|
||||
accessibilityLabel="Најави се"
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Најави се
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
|
||||
<Text style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
|
||||
<Text
|
||||
accessible
|
||||
accessibilityLabel="Регистрирајте се"
|
||||
accessibilityRole="link"
|
||||
style={{ color: theme.colors.primary, fontWeight: "600" }}
|
||||
onPress={() => router.push("/(auth)/register")}
|
||||
>
|
||||
Регистрирајте се
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.sm }}>
|
||||
<Text style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
|
||||
<Text
|
||||
accessible
|
||||
accessibilityLabel="Регистрирајте се"
|
||||
accessibilityRole="link"
|
||||
style={{ color: theme.colors.primary, fontWeight: "600" }}
|
||||
onPress={() => router.push("/(auth)/register")}
|
||||
>
|
||||
Регистрирајте се
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,104 +67,163 @@ export default function RegisterScreen() {
|
||||
style={{ flex: 1, backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
||||
contentContainerStyle={{ flexGrow: 1, padding: theme.spacing.lg }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={{ marginTop: 40, marginBottom: theme.spacing.md }}>
|
||||
<Text style={{ fontSize: 28, fontWeight: "700", color: theme.colors.text }}>
|
||||
Креирајте профил
|
||||
</Text>
|
||||
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
|
||||
Изберете ја вашата улога
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: "row", gap: theme.spacing.md }}>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Изберете улога: Клиент"
|
||||
accessibilityRole="button"
|
||||
onPress={() => setRole("customer")}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing.md,
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
borderRadius: theme.radius.md,
|
||||
borderWidth: 2,
|
||||
borderColor: role === "customer" ? theme.colors.primary : theme.colors.border,
|
||||
backgroundColor: role === "customer" ? theme.colors.primaryLight : theme.colors.surface,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing.sm,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 32 }}>🏠</Text>
|
||||
<Text style={{ fontWeight: "600", color: theme.colors.text }}>Клиент</Text>
|
||||
<Text style={{ fontSize: 12, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
Барајте мајстори
|
||||
<View style={{
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.radius.xl,
|
||||
padding: theme.spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderLight,
|
||||
...theme.shadows.md,
|
||||
gap: theme.spacing.md,
|
||||
}}>
|
||||
<View style={{ alignItems: "center", marginBottom: theme.spacing.sm }}>
|
||||
<View
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: theme.colors.primaryLight,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: theme.spacing.md,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 32 }}>👤</Text>
|
||||
</View>
|
||||
<Text style={{ ...theme.typography.h1, color: theme.colors.text }}>
|
||||
Креирајте профил
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Изберете улога: Мајстор"
|
||||
accessibilityRole="button"
|
||||
onPress={() => setRole("handyman")}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing.md,
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
borderRadius: theme.radius.md,
|
||||
borderWidth: 2,
|
||||
borderColor: role === "handyman" ? theme.colors.primary : theme.colors.border,
|
||||
backgroundColor: role === "handyman" ? theme.colors.primaryLight : theme.colors.surface,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing.sm,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 32 }}>🔧</Text>
|
||||
<Text style={{ fontWeight: "600", color: theme.colors.text }}>Мајстор</Text>
|
||||
<Text style={{ fontSize: 12, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
Нудете услуги
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
|
||||
Изберете ја вашата улога
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Input placeholder="Име и презиме" value={name} onChangeText={setName} accessibilityLabel="Име и презиме" />
|
||||
<Input
|
||||
placeholder="Е-пошта"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
accessibilityLabel="Е-пошта"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Телефонски број (незадолжително)"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
keyboardType="phone-pad"
|
||||
accessibilityLabel="Телефонски број"
|
||||
/>
|
||||
<Input placeholder="Лозинка (мин. 8 карактери)" value={password} onChangeText={setPassword} secureTextEntry accessibilityLabel="Лозинка" />
|
||||
<View style={{ flexDirection: "row", gap: theme.spacing.md }}>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Изберете улога: Клиент"
|
||||
accessibilityRole="button"
|
||||
onPress={() => setRole("customer")}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing.lg,
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
borderRadius: theme.radius.md,
|
||||
borderWidth: 2,
|
||||
borderColor: role === "customer" ? theme.colors.primary : theme.colors.borderLight,
|
||||
backgroundColor: role === "customer" ? theme.colors.primaryLight : theme.colors.surface,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing.sm,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: role === "customer" ? theme.colors.primary + "20" : theme.colors.surfaceAlt,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 24 }}>🏠</Text>
|
||||
</View>
|
||||
<Text style={{ fontWeight: "600", fontSize: 15, color: theme.colors.text }}>Клиент</Text>
|
||||
<Text style={{ fontSize: 12, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
Барајте мајстори
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={{ marginTop: theme.spacing.sm }}>
|
||||
<Button loading={loading} onPress={handleRegister} accessibilityLabel="Регистрирај се">
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Изберете улога: Мајстор"
|
||||
accessibilityRole="button"
|
||||
onPress={() => setRole("handyman")}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing.lg,
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
borderRadius: theme.radius.md,
|
||||
borderWidth: 2,
|
||||
borderColor: role === "handyman" ? theme.colors.primary : theme.colors.borderLight,
|
||||
backgroundColor: role === "handyman" ? theme.colors.primaryLight : theme.colors.surface,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing.sm,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: role === "handyman" ? theme.colors.primary + "20" : theme.colors.surfaceAlt,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 24 }}>🔧</Text>
|
||||
</View>
|
||||
<Text style={{ fontWeight: "600", fontSize: 15, color: theme.colors.text }}>Мајстор</Text>
|
||||
<Text style={{ fontSize: 12, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
Нудете услуги
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
placeholder="Име и презиме"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
accessibilityLabel="Име и презиме"
|
||||
label="Име и презиме"
|
||||
/>
|
||||
<Input
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
accessibilityLabel="Е-пошта"
|
||||
label="Е-пошта"
|
||||
/>
|
||||
<Input
|
||||
placeholder="+389 70 123 456"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
keyboardType="phone-pad"
|
||||
accessibilityLabel="Телефонски број"
|
||||
label="Телефон (незадолжително)"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Минимум 8 карактери"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
accessibilityLabel="Лозинка"
|
||||
label="Лозинка"
|
||||
/>
|
||||
|
||||
<Button loading={loading} onPress={handleRegister} accessibilityLabel="Регистрирај се" size="lg" fullWidth>
|
||||
Регистрирај се
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
|
||||
<Text style={{ color: theme.colors.textSecondary }}>Веќе имате профил? </Text>
|
||||
<Text
|
||||
accessible
|
||||
accessibilityLabel="Најавете се"
|
||||
accessibilityRole="link"
|
||||
style={{ color: theme.colors.primary, fontWeight: "600" }} onPress={() => router.back()}>
|
||||
Најавете се
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.sm }}>
|
||||
<Text style={{ color: theme.colors.textSecondary }}>Веќе имате профил? </Text>
|
||||
<Text
|
||||
accessible
|
||||
accessibilityLabel="Најавете се"
|
||||
accessibilityRole="link"
|
||||
style={{ color: theme.colors.primary, fontWeight: "600" }}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
Најавете се
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,16 +39,27 @@ export default function ExploreScreen() {
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||
}
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||
<Link href="/(tabs)/(explore)" asChild>
|
||||
<Pressable accessible accessibilityLabel="Назад кон сите категории" accessibilityRole="link" style={{ marginRight: theme.spacing.xs }}>
|
||||
<Text style={{ fontSize: 20, color: theme.colors.primary }}>← Сите</Text>
|
||||
<Text style={{ ...theme.typography.bodyBold, color: theme.colors.primary }}>← Сите</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
<Text style={{ fontSize: 28 }}>{catEmoji}</Text>
|
||||
<Text selectable style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: theme.colors.primaryLight,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 22 }}>{catEmoji}</Text>
|
||||
</View>
|
||||
<Text selectable style={{ ...theme.typography.h2, color: theme.colors.text }}>
|
||||
{catName}
|
||||
</Text>
|
||||
</View>
|
||||
@ -56,9 +67,12 @@ export default function ExploreScreen() {
|
||||
{categoryAdsResult.status === "LoadingFirstPage" ? (
|
||||
<Loading />
|
||||
) : categoryAdsResult.results.length === 0 ? (
|
||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||
Нема огласи во оваа категорија
|
||||
</Text>
|
||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>🔎</Text>
|
||||
<Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
|
||||
Нема огласи во оваа категорија
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
{categoryAdsResult.results.map((ad) => (
|
||||
@ -74,15 +88,15 @@ export default function ExploreScreen() {
|
||||
accessibilityRole="button"
|
||||
onPress={() => categoryAdsResult.loadMore(20)}
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
backgroundColor: theme.colors.surfaceAlt,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
@ -95,24 +109,27 @@ export default function ExploreScreen() {
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||
}
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
|
||||
>
|
||||
<Text style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text }}>
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
|
||||
Пребарувај по категорија
|
||||
</Text>
|
||||
|
||||
<CategoryGrid categories={displayCategories} />
|
||||
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text, marginTop: theme.spacing.sm }}>
|
||||
Сите огласи
|
||||
</Text>
|
||||
|
||||
{allAdsResult.status === "LoadingFirstPage" ? (
|
||||
<Loading />
|
||||
) : allAdsResult.results.length === 0 ? (
|
||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||
Нема огласи
|
||||
</Text>
|
||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>📦</Text>
|
||||
<Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
|
||||
Нема огласи
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
{allAdsResult.results.map((ad) => (
|
||||
@ -128,17 +145,17 @@ export default function ExploreScreen() {
|
||||
accessibilityRole="button"
|
||||
onPress={() => allAdsResult.loadMore(20)}
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
backgroundColor: theme.colors.surfaceAlt,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
import { View, Text, TextInput, ScrollView, RefreshControl, Pressable } from "react-native";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTheme } from "../../../components/theme";
|
||||
import { useQuery, usePaginatedQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { useAuth } from "../../_layout";
|
||||
import { CategoryGrid } from "../../../components/category-grid";
|
||||
import { AdCard } from "../../../components/ad-card";
|
||||
import { Loading } from "../../../components/ui/loading";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { CATEGORIES } from "../../../lib/constants";
|
||||
|
||||
export default function HomeScreen() {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { userId } = useAuth();
|
||||
const [search, setSearch] = useState("");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
@ -40,81 +45,121 @@ export default function HomeScreen() {
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
||||
}
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 40 }}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
>
|
||||
<View style={{ marginTop: theme.spacing.md }}>
|
||||
<Text style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text }}>
|
||||
Добредојдовте 🏠
|
||||
</Text>
|
||||
<Text selectable style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
|
||||
Најдете мајстор во вашата близина
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
accessible
|
||||
accessibilityLabel="Пребарувај мајстори"
|
||||
accessibilityRole="search"
|
||||
placeholder="Пребарувај мајстори..."
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.radius.md,
|
||||
padding: theme.spacing.md,
|
||||
fontSize: 16,
|
||||
color: theme.colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
}}
|
||||
/>
|
||||
|
||||
{!search.trim() && (
|
||||
<>
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
|
||||
Категории
|
||||
<View style={{ padding: theme.spacing.lg, gap: theme.spacing.md }}>
|
||||
<View style={{ marginTop: theme.spacing.md }}>
|
||||
<Text style={{ ...theme.typography.h1, color: theme.colors.text }}>
|
||||
Добредојдовте
|
||||
</Text>
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
|
||||
Најдете мајстор во вашата близина
|
||||
</Text>
|
||||
<CategoryGrid categories={displayCategories} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginTop: theme.spacing.sm }}>
|
||||
{search.trim() ? "Резултати од пребарување" : "Неодамна додадени"}
|
||||
</Text>
|
||||
|
||||
{(search.trim() ? searchResults === undefined : isLoading) ? (
|
||||
<Loading />
|
||||
) : displayAds.length === 0 ? (
|
||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
||||
{search.trim() ? "Не се пронајдени резултати" : "Нема огласи"}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
{displayAds.map((ad) => (
|
||||
<AdCard key={ad._id} ad={ad} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!search.trim() && adsStatus === "CanLoadMore" && (
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Вчитај повеќе огласи"
|
||||
accessibilityRole="button"
|
||||
onPress={() => loadMoreAds(20)}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderWidth: 1.5,
|
||||
borderColor: theme.colors.borderLight,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Text style={{ fontSize: 18 }}>🔍</Text>
|
||||
<TextInput
|
||||
accessible
|
||||
accessibilityLabel="Пребарувај мајстори"
|
||||
accessibilityRole="search"
|
||||
placeholder="Пребарувај мајстори..."
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
color: theme.colors.text,
|
||||
}}
|
||||
/>
|
||||
{search.length > 0 && (
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Исчисти пребарување"
|
||||
onPress={() => setSearch("")}
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<Text style={{ color: theme.colors.textTertiary, fontSize: 18 }}>✕</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!search.trim() && (
|
||||
<>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
|
||||
Категории
|
||||
</Text>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Види ги сите категории"
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push("/(tabs)/(explore)")}
|
||||
>
|
||||
<Text style={{ ...theme.typography.bodyBold, color: theme.colors.primary }}>
|
||||
Сите
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<CategoryGrid categories={displayCategories} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
|
||||
{search.trim() ? "Резултати од пребарување" : "Неодамна додадени"}
|
||||
</Text>
|
||||
|
||||
{(search.trim() ? searchResults === undefined : isLoading) ? (
|
||||
<Loading />
|
||||
) : displayAds.length === 0 ? (
|
||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
|
||||
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>🔎</Text>
|
||||
<Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
|
||||
{search.trim() ? "Не се пронајдени резултати" : "Нема огласи"}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
{displayAds.map((ad) => (
|
||||
<AdCard key={ad._id} ad={ad} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!search.trim() && adsStatus === "CanLoadMore" && (
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Вчитај повеќе огласи"
|
||||
accessibilityRole="button"
|
||||
onPress={() => loadMoreAds(20)}
|
||||
style={{
|
||||
backgroundColor: theme.colors.surfaceAlt,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>
|
||||
Вчитај повеќе
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { useAuth } from "../../_layout";
|
||||
import { useTheme } from "../../../components/theme";
|
||||
import { PostCard } from "../../../components/post-card";
|
||||
import { Loading } from "../../../components/ui/loading";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
|
||||
const TABS = [
|
||||
{ key: "open", label: "Отворени" },
|
||||
@ -48,29 +49,21 @@ export default function PostsScreen() {
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
||||
>
|
||||
{isAuthenticated && isCustomer && (
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Креирај ново побарување"
|
||||
accessibilityRole="button"
|
||||
<Button
|
||||
onPress={() => router.push("/post/create" as any)}
|
||||
size="lg"
|
||||
accessibilityLabel="Креирај ново побарување"
|
||||
>
|
||||
<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>
|
||||
</View>
|
||||
</Pressable>
|
||||
+ Ново побарување
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<View style={{ flexDirection: "row", backgroundColor: theme.colors.surface, borderRadius: theme.radius.md, padding: 4 }}>
|
||||
<View style={{
|
||||
flexDirection: "row",
|
||||
backgroundColor: theme.colors.surfaceAlt,
|
||||
borderRadius: theme.radius.md,
|
||||
padding: 3,
|
||||
}}>
|
||||
{TABS.map((tab) => (
|
||||
<Pressable
|
||||
key={tab.key}
|
||||
@ -85,9 +78,7 @@ export default function PostsScreen() {
|
||||
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 }
|
||||
: {}),
|
||||
...(activeTab === tab.key ? theme.shadows.sm : undefined),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
@ -107,14 +98,14 @@ export default function PostsScreen() {
|
||||
<Loading />
|
||||
) : postsResult.results.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 style={{ fontSize: 48, marginBottom: theme.spacing.md }}>📋</Text>
|
||||
<Text style={{ ...theme.typography.h3, color: theme.colors.text, marginBottom: theme.spacing.sm }}>
|
||||
Нема побарувања
|
||||
</Text>
|
||||
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
{activeTab === "open"
|
||||
? "Нема отворени побарувања во моментот."
|
||||
: "Сеуште нема побарувања."}
|
||||
: "Сè уште нема побарувања."}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
@ -132,17 +123,17 @@ export default function PostsScreen() {
|
||||
accessibilityRole="button"
|
||||
onPress={() => postsResult.loadMore(20)}
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
backgroundColor: theme.colors.surfaceAlt,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,13 +7,11 @@ import { useAuth } from "../../_layout";
|
||||
import { useTheme } from "../../../components/theme";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Rating } from "../../../components/ui/rating";
|
||||
import { Loading } from "../../../components/ui/loading";
|
||||
import { Card } from "../../../components/ui/card";
|
||||
import { Avatar } from "../../../components/ui/avatar";
|
||||
import { AdCard } from "../../../components/ad-card";
|
||||
import { PostCard } from "../../../components/post-card";
|
||||
import { getCategoryName } from "../../../lib/constants";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const router = useRouter();
|
||||
@ -73,15 +71,23 @@ export default function ProfileScreen() {
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<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 }}>
|
||||
<View style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.spacing.lg,
|
||||
}}>
|
||||
<Text style={{ fontSize: 64, marginBottom: theme.spacing.md }}>👤</Text>
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text, marginBottom: theme.spacing.sm }}>
|
||||
Најавете се
|
||||
</Text>
|
||||
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center", marginBottom: theme.spacing.lg }}>
|
||||
За да ги видите вашиот профил и податоци.
|
||||
</Text>
|
||||
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
||||
<Button onPress={() => router.push("/(auth)/login")} size="lg">
|
||||
Најави се
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -106,52 +112,55 @@ export default function ProfileScreen() {
|
||||
}
|
||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
||||
>
|
||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||||
<Card variant="elevated" style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
|
||||
<Avatar uri={user.avatarId ?? null} name={user.name || "Корисник"} size={80} />
|
||||
<Text selectable style={{ fontSize: 20, fontWeight: "700", color: theme.colors.text, marginTop: theme.spacing.md }}>
|
||||
<Text selectable style={{ ...theme.typography.h2, color: theme.colors.text, marginTop: theme.spacing.md }}>
|
||||
{user.name || "Корисник"}
|
||||
</Text>
|
||||
<Badge label={isHandyman ? "Мајстор" : "Клиент"} variant={isHandyman ? "primary" : "default"} />
|
||||
{user.email && (
|
||||
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14, marginTop: theme.spacing.xs }}>
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
{user.phone && (
|
||||
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary }}>
|
||||
{user.phone}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{isHandyman && (
|
||||
<>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
|
||||
Мои огласи
|
||||
</Text>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Креирај нов оглас"
|
||||
accessibilityRole="button"
|
||||
<Button
|
||||
size="sm"
|
||||
onPress={() => router.push("/ad/create" as any)}
|
||||
accessibilityLabel="Креирај нов оглас"
|
||||
>
|
||||
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
|
||||
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Нов оглас</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
+ Нов оглас
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{myAdsResult.status === "LoadingFirstPage" ? (
|
||||
<Loading />
|
||||
) : myAds.length === 0 ? (
|
||||
<Card>
|
||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||||
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📋</Text>
|
||||
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
|
||||
Сеуште немате огласи. Креирајте нов оглас за да се прикажете на клиентите.
|
||||
</Text>
|
||||
</View>
|
||||
<Card style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
|
||||
<Text style={{ fontSize: 48, marginBottom: theme.spacing.sm }}>📋</Text>
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
Сè уште немате огласи.
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => router.push("/ad/create" as any)}
|
||||
style={{ marginTop: theme.spacing.md }}
|
||||
>
|
||||
Креирај оглас
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
@ -171,7 +180,7 @@ export default function ProfileScreen() {
|
||||
paddingVertical: 10,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Уреди</Text>
|
||||
@ -204,15 +213,15 @@ export default function ProfileScreen() {
|
||||
accessibilityRole="button"
|
||||
onPress={() => myAdsResult.loadMore(20)}
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
backgroundColor: theme.colors.surfaceAlt,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
@ -221,31 +230,34 @@ export default function ProfileScreen() {
|
||||
{!isHandyman && (
|
||||
<>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
|
||||
Мои побарувања
|
||||
</Text>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Креирај ново побарување"
|
||||
accessibilityRole="button"
|
||||
<Button
|
||||
size="sm"
|
||||
onPress={() => router.push("/post/create" as any)}
|
||||
accessibilityLabel="Креирај ново побарување"
|
||||
>
|
||||
<View style={{ backgroundColor: theme.colors.primary, borderRadius: theme.radius.md, paddingHorizontal: theme.spacing.md, paddingVertical: theme.spacing.sm }}>
|
||||
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 14 }}>+ Ново побарување</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
+ Ново
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{myPostsResult.status === "LoadingFirstPage" ? (
|
||||
<Loading />
|
||||
) : myPosts.length === 0 ? (
|
||||
<Card>
|
||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
||||
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📝</Text>
|
||||
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
|
||||
Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор.
|
||||
</Text>
|
||||
</View>
|
||||
<Card style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
|
||||
<Text style={{ fontSize: 48, marginBottom: theme.spacing.sm }}>📝</Text>
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
Сè уште немате побарувања.
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => router.push("/post/create" as any)}
|
||||
style={{ marginTop: theme.spacing.md }}
|
||||
>
|
||||
Креирај побарување
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
@ -262,32 +274,43 @@ export default function ProfileScreen() {
|
||||
accessibilityRole="button"
|
||||
onPress={() => myPostsResult.loadMore(20)}
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
backgroundColor: theme.colors.surfaceAlt,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
|
||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 15 }}>Вчитај повеќе</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={{ backgroundColor: theme.colors.card, borderRadius: theme.radius.lg, padding: theme.spacing.md, gap: theme.spacing.sm, borderWidth: 1, borderColor: theme.colors.border }}>
|
||||
<Pressable accessible accessibilityLabel="Оцени" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
|
||||
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Оцени</Text>
|
||||
<Card>
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Поставки"
|
||||
accessibilityRole="button"
|
||||
style={{ paddingVertical: theme.spacing.sm }}
|
||||
>
|
||||
<Text style={{ ...theme.typography.body, color: theme.colors.text }}>Поставки</Text>
|
||||
</Pressable>
|
||||
<Pressable accessible accessibilityLabel="Поставки" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
|
||||
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Поставки</Text>
|
||||
<View style={{ height: 1, backgroundColor: theme.colors.borderLight, marginVertical: theme.spacing.xs }} />
|
||||
<Pressable
|
||||
accessible
|
||||
accessibilityLabel="Оцени"
|
||||
accessibilityRole="button"
|
||||
style={{ paddingVertical: theme.spacing.sm }}
|
||||
>
|
||||
<Text style={{ ...theme.typography.body, color: theme.colors.text }}>Оцени ја апликацијата</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button variant="destructive" onPress={handleLogout}>
|
||||
<Button variant="destructive" onPress={handleLogout} fullWidth>
|
||||
Одјави се
|
||||
</Button>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,26 +1,35 @@
|
||||
import { Tabs } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTheme } from "../../components/theme";
|
||||
|
||||
export default function TabLayout() {
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
tabBarInactiveTintColor: theme.colors.textTertiary,
|
||||
tabBarActiveTintColor: theme.colors.tabActive,
|
||||
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.card,
|
||||
borderTopColor: theme.colors.border,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.borderLight,
|
||||
paddingTop: 4,
|
||||
paddingBottom: insets.bottom,
|
||||
height: 56 + insets.bottom,
|
||||
},
|
||||
tabBarLabelStyle: { fontSize: 11 },
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: "500", marginTop: -2 },
|
||||
tabBarHideOnKeyboard: true,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="(home)"
|
||||
options={{
|
||||
title: "Почетна",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
|
||||
tabBarAccessibilityLabel: "Почетна",
|
||||
}}
|
||||
/>
|
||||
@ -28,6 +37,7 @@ export default function TabLayout() {
|
||||
name="(explore)"
|
||||
options={{
|
||||
title: "Пребарување",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="search-outline" size={size} color={color} />,
|
||||
tabBarAccessibilityLabel: "Пребарување",
|
||||
}}
|
||||
/>
|
||||
@ -35,6 +45,7 @@ export default function TabLayout() {
|
||||
name="(posts)"
|
||||
options={{
|
||||
title: "Побарувања",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="document-text-outline" size={size} color={color} />,
|
||||
tabBarAccessibilityLabel: "Побарувања",
|
||||
}}
|
||||
/>
|
||||
@ -42,6 +53,7 @@ export default function TabLayout() {
|
||||
name="(chat)"
|
||||
options={{
|
||||
title: "Чат",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubble-outline" size={size} color={color} />,
|
||||
tabBarAccessibilityLabel: "Чат",
|
||||
}}
|
||||
/>
|
||||
@ -49,9 +61,10 @@ export default function TabLayout() {
|
||||
name="(profile)"
|
||||
options={{
|
||||
title: "Профил",
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="person-outline" size={size} color={color} />,
|
||||
tabBarAccessibilityLabel: "Профил",
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Stack } from "expo-router/stack";
|
||||
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||||
import { useState, createContext, useContext, useEffect, useCallback, Component, type ReactNode, type ErrorInfo } from "react";
|
||||
import { View, Text, Pressable, ScrollView } from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { ThemeProvider, useTheme } from "../components/theme";
|
||||
|
||||
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
||||
@ -86,8 +87,8 @@ export default function RootLayout() {
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
localStorage.getItem(TOKEN_KEY),
|
||||
localStorage.getItem(USER_ID_KEY),
|
||||
AsyncStorage.getItem(TOKEN_KEY),
|
||||
AsyncStorage.getItem(USER_ID_KEY),
|
||||
]).then(([t, u]) => {
|
||||
if (t) setToken(t);
|
||||
if (u) setUserId(u);
|
||||
@ -96,15 +97,15 @@ export default function RootLayout() {
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (newToken: string, newUserId: string) => {
|
||||
localStorage.setItem(TOKEN_KEY, newToken);
|
||||
localStorage.setItem(USER_ID_KEY, newUserId);
|
||||
await AsyncStorage.setItem(TOKEN_KEY, newToken);
|
||||
await AsyncStorage.setItem(USER_ID_KEY, newUserId);
|
||||
setToken(newToken);
|
||||
setUserId(newUserId);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_ID_KEY);
|
||||
await AsyncStorage.removeItem(TOKEN_KEY);
|
||||
await AsyncStorage.removeItem(USER_ID_KEY);
|
||||
setToken(null);
|
||||
setUserId(null);
|
||||
}, []);
|
||||
|
||||
112
app/ad/[id].tsx
112
app/ad/[id].tsx
@ -143,54 +143,85 @@ export default function AdDetailScreen() {
|
||||
accessible
|
||||
accessibilityLabel={`Слика за ${ad.title}`}
|
||||
style={{
|
||||
backgroundColor: theme.colors.surface,
|
||||
backgroundColor: theme.colors.surfaceAlt,
|
||||
borderRadius: theme.radius.lg,
|
||||
aspectRatio: 16 / 9,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderLight,
|
||||
borderStyle: "dashed",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 48 }}>{categoryEmoji}</Text>
|
||||
<Text style={{ color: theme.colors.textTertiary, marginTop: theme.spacing.sm, fontSize: 14 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: theme.colors.primaryLight,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 32 }}>{categoryEmoji}</Text>
|
||||
</View>
|
||||
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary, marginTop: theme.spacing.sm }}>
|
||||
Нема слики
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
|
||||
{ad.priceRange && (
|
||||
<Text selectable style={{ fontSize: 18, fontWeight: "600", color: theme.colors.primary, marginTop: theme.spacing.xs }}>
|
||||
{ad.priceRange}
|
||||
<View style={{ gap: theme.spacing.xs }}>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>{ad.title}</Text>
|
||||
</View>
|
||||
{ad.priceRange && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.colors.primaryLight,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: theme.radius.sm,
|
||||
marginLeft: theme.spacing.sm,
|
||||
}}
|
||||
>
|
||||
<Text selectable style={{ fontSize: 15, fontWeight: "700", color: theme.colors.primaryDark }}>
|
||||
{ad.priceRange}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
|
||||
<Badge label={categoryName} variant="primary" />
|
||||
<Text selectable style={{ ...theme.typography.caption, color: theme.colors.textSecondary }}>
|
||||
📍 {ad.location}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
||||
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={18} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
|
||||
<Badge label={categoryName} variant="primary" />
|
||||
<Text selectable style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 {ad.location}</Text>
|
||||
</View>
|
||||
|
||||
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
||||
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={20} />
|
||||
)}
|
||||
|
||||
{ad.availability && (
|
||||
<Card>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||
<Text style={{ ...theme.typography.captionBold, color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||
Расположивост
|
||||
</Text>
|
||||
<Text selectable style={{ fontSize: 14, color: theme.colors.textSecondary }}>{ad.availability}</Text>
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary }}>
|
||||
{ad.availability}
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||
<Text style={{ ...theme.typography.captionBold, color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||
Опис
|
||||
</Text>
|
||||
<Text style={{ fontSize: 15, color: theme.colors.textSecondary, lineHeight: 22 }}>{ad.description}</Text>
|
||||
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary, lineHeight: 22 }}>
|
||||
{ad.description}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@ -205,24 +236,33 @@ export default function AdDetailScreen() {
|
||||
>
|
||||
<Avatar uri={handyman?.avatarId ?? null} name={handyman?.name || "Мајстор"} size={48} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
|
||||
<Text style={{ ...theme.typography.bodyBold, color: theme.colors.text }}>
|
||||
{handyman?.name || "Мајстор"}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: theme.colors.textSecondary }}>Мајстор</Text>
|
||||
<Text style={{ ...theme.typography.caption, color: theme.colors.textSecondary }}>
|
||||
Мајстор
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ color: theme.colors.primary, fontSize: 20 }}>›</Text>
|
||||
</Pressable>
|
||||
</Card>
|
||||
|
||||
{!isOwner && isAuthenticated && (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
<Button loading={startingChat} onPress={handleStartChat}>Започни разговор</Button>
|
||||
<Button loading={startingChat} onPress={handleStartChat} size="lg" fullWidth>
|
||||
Започни разговор
|
||||
</Button>
|
||||
{!existingUserReview && (
|
||||
<Button variant="outline" onPress={() => router.push(`/review/create?adId=${ad._id}` as any)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={() => router.push(`/review/create?adId=${ad._id}` as any)}
|
||||
fullWidth
|
||||
>
|
||||
Напиши оценка
|
||||
</Button>
|
||||
)}
|
||||
{existingUserReview && (
|
||||
<Text style={{ fontSize: 13, color: theme.colors.textTertiary, textAlign: "center" }}>
|
||||
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary, textAlign: "center" }}>
|
||||
Веќе напишавте оценка за овој оглас
|
||||
</Text>
|
||||
)}
|
||||
@ -234,10 +274,11 @@ export default function AdDetailScreen() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onPress={() => router.push(`/ad/create?editId=${ad._id}` as any)}
|
||||
fullWidth
|
||||
>
|
||||
Уреди оглас
|
||||
</Button>
|
||||
<Button variant="destructive" onPress={handleDelete}>
|
||||
<Button variant="destructive" onPress={handleDelete} fullWidth>
|
||||
Избриши оглас
|
||||
</Button>
|
||||
</View>
|
||||
@ -245,27 +286,28 @@ export default function AdDetailScreen() {
|
||||
|
||||
{!isOwner && !isAuthenticated && (
|
||||
<Card>
|
||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||
Најавете се за да започнете разговор или да напишете оценка.
|
||||
</Text>
|
||||
<View style={{ marginTop: theme.spacing.md }}>
|
||||
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
||||
<Button onPress={() => router.push("/(auth)/login")} fullWidth>
|
||||
Најави се
|
||||
</Button>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reviews section */}
|
||||
{reviews && reviews.length > 0 && (
|
||||
<View style={{ gap: theme.spacing.sm }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
|
||||
Оцени
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.xs }}>
|
||||
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
|
||||
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>
|
||||
{ad.ratingAvg?.toFixed(1)}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: theme.colors.textTertiary }}>
|
||||
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary }}>
|
||||
({ad.reviewCount} {ad.reviewCount === 1 ? "оценка" : "оцени"})
|
||||
</Text>
|
||||
</View>
|
||||
@ -287,4 +329,4 @@ export default function AdDetailScreen() {
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { Link } from "expo-router";
|
||||
import { useTheme } from "./theme";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Rating } from "./ui/rating";
|
||||
import { getCategoryName } from "../lib/constants";
|
||||
import { getCategoryName, CATEGORY_EMOJI } from "../lib/constants";
|
||||
|
||||
interface AdCardProps {
|
||||
ad: {
|
||||
@ -20,6 +20,7 @@ interface AdCardProps {
|
||||
export function AdCard({ ad }: AdCardProps) {
|
||||
const theme = useTheme();
|
||||
const categoryName = getCategoryName(ad.category);
|
||||
const emoji = CATEGORY_EMOJI[ad.category] ?? "🔧";
|
||||
|
||||
return (
|
||||
<Link href={`/ad/${ad._id}` as any} asChild>
|
||||
@ -30,33 +31,58 @@ export function AdCard({ ad }: AdCardProps) {
|
||||
style={{
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.radius.lg,
|
||||
padding: theme.spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing.sm,
|
||||
borderColor: theme.colors.borderLight,
|
||||
...theme.shadows.sm,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
|
||||
{ad.title}
|
||||
</Text>
|
||||
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
||||
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={14} />
|
||||
<View style={{ padding: theme.spacing.md, gap: theme.spacing.sm }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: theme.colors.primaryLight,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20 }}>{emoji}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<Text style={{ ...theme.typography.h3, color: theme.colors.text }} numberOfLines={1}>
|
||||
{ad.title}
|
||||
</Text>
|
||||
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
||||
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={13} />
|
||||
)}
|
||||
</View>
|
||||
{ad.priceRange && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.colors.primaryLight,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: theme.radius.sm,
|
||||
}}
|
||||
>
|
||||
<Text selectable style={{ fontSize: 13, fontWeight: "700", color: theme.colors.primaryDark }}>
|
||||
{ad.priceRange}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{ad.priceRange && (
|
||||
<Text selectable style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary, marginLeft: 8 }}>
|
||||
{ad.priceRange}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<Badge label={categoryName} variant="primary" />
|
||||
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {ad.location}</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<Badge label={categoryName} variant="primary" size="sm" />
|
||||
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary }}>
|
||||
📍 {ad.location}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,13 @@ interface CategoryGridProps {
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
function getCategoryColor(slug: string): string {
|
||||
const colors = ["#D4764A", "#2B5797", "#4A8C6F", "#C47A4A", "#8B5CF6", "#E07B5A",
|
||||
"#3B82F6", "#7C3AED", "#06B6D4", "#D97706", "#059669", "#6B7280"];
|
||||
const idx = slug.split("").reduce((a, c) => a + c.charCodeAt(0), 0);
|
||||
return colors[idx % colors.length];
|
||||
}
|
||||
|
||||
export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
@ -22,6 +29,7 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
|
||||
{categories.map((cat) => {
|
||||
const emoji = CATEGORY_EMOJI[cat.slug] ?? cat.icon ?? "📋";
|
||||
const color = getCategoryColor(cat.slug);
|
||||
return (
|
||||
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
|
||||
<Pressable
|
||||
@ -32,22 +40,35 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
paddingHorizontal: theme.spacing.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderLight,
|
||||
width: `${Math.floor((100 - (columns - 1) * 3.3) / columns)}%` as any,
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 28 }}>{emoji}</Text>
|
||||
<View
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: color + "18",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 22 }}>{emoji}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: "500",
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
lineHeight: 16,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{cat.name}
|
||||
</Text>
|
||||
@ -57,4 +78,4 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,42 +58,28 @@ export function ChatListItem({ chat, currentUserId }: ChatListItemProps) {
|
||||
paddingVertical: theme.spacing.md,
|
||||
paddingHorizontal: theme.spacing.lg,
|
||||
gap: theme.spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.card,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.borderLight,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
uri={chat.otherUser?.avatarId ?? null}
|
||||
name={chat.otherUser?.name ?? "Непознат"}
|
||||
size={50}
|
||||
size={52}
|
||||
/>
|
||||
<View style={{ flex: 1, gap: 2 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text, flex: 1 }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
<View style={{ flex: 1, gap: 3 }}>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Text style={{ ...theme.typography.bodyBold, color: theme.colors.text, flex: 1 }} numberOfLines={1}>
|
||||
{chat.otherUser?.name ?? "Непознат"}
|
||||
</Text>
|
||||
{chat.lastMessage && (
|
||||
<Text
|
||||
style={{ fontSize: 12, color: theme.colors.textTertiary, marginLeft: 8 }}
|
||||
>
|
||||
<Text style={{ ...theme.typography.label, color: theme.colors.textTertiary, marginLeft: 8 }}>
|
||||
{formatRelativeTime(chat.lastMessage.createdAt)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{ fontSize: 14, color: theme.colors.textSecondary }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
<Text style={{ ...theme.typography.caption, color: theme.colors.textSecondary }} numberOfLines={1}>
|
||||
{previewText}
|
||||
</Text>
|
||||
</View>
|
||||
@ -102,4 +88,4 @@ export function ChatListItem({ chat, currentUserId }: ChatListItemProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export { formatRelativeTime };
|
||||
export { formatRelativeTime };
|
||||
|
||||
@ -2,7 +2,7 @@ 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";
|
||||
import { getCategoryName, CATEGORY_EMOJI } from "../lib/constants";
|
||||
|
||||
interface PostCardProps {
|
||||
post: {
|
||||
@ -20,6 +20,7 @@ interface PostCardProps {
|
||||
export function PostCard({ post }: PostCardProps) {
|
||||
const theme = useTheme();
|
||||
const categoryName = post.category ? getCategoryName(post.category) : null;
|
||||
const emoji = post.category ? (CATEGORY_EMOJI[post.category] ?? "📋") : "📋";
|
||||
const isOpen = post.status === "open";
|
||||
|
||||
return (
|
||||
@ -31,35 +32,56 @@ export function PostCard({ post }: PostCardProps) {
|
||||
style={{
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.radius.lg,
|
||||
padding: theme.spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing.sm,
|
||||
borderColor: theme.colors.borderLight,
|
||||
...theme.shadows.sm,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
<View style={{
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
}}>
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.radius.md,
|
||||
backgroundColor: isOpen ? theme.colors.successLight : theme.colors.surfaceAlt,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18 }}>{emoji}</Text>
|
||||
</View>
|
||||
<Text style={{ ...theme.typography.h3, color: theme.colors.text, flex: 1 }} numberOfLines={1}>
|
||||
{post.title}
|
||||
</Text>
|
||||
</View>
|
||||
<Badge label={isOpen ? "Отворено" : "Затворено"} variant={isOpen ? "success" : "error"} size="sm" />
|
||||
</View>
|
||||
|
||||
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary }} numberOfLines={2}>
|
||||
{post.description}
|
||||
</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 selectable style={{ fontSize: 13, color: theme.colors.primary, fontWeight: "600" }}>
|
||||
💰 {post.budget}
|
||||
</Text>
|
||||
)}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
{categoryName && <Badge label={categoryName} variant="primary" size="sm" />}
|
||||
{post.location && (
|
||||
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary }}>
|
||||
📍 {post.location}
|
||||
</Text>
|
||||
)}
|
||||
{post.budget && (
|
||||
<Text selectable style={{ ...theme.typography.captionBold, color: theme.colors.primary }}>
|
||||
💰 {post.budget}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,28 +45,28 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar
|
||||
borderRadius: theme.radius.lg,
|
||||
padding: theme.spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderLight,
|
||||
gap: theme.spacing.sm,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||
<Avatar uri={customerAvatarId ?? null} name={customerName} size={36} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text selectable style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>
|
||||
<Text selectable style={{ ...theme.typography.bodyBold, color: theme.colors.text }}>
|
||||
{customerName}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: theme.colors.textTertiary }}>
|
||||
<Text style={{ ...theme.typography.label, color: theme.colors.textTertiary }}>
|
||||
{formatTimeAgo(review.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
<Rating value={review.rating} size={16} />
|
||||
<Rating value={review.rating} size={14} />
|
||||
</View>
|
||||
|
||||
{review.comment ? (
|
||||
<Text selectable style={{ fontSize: 14, color: theme.colors.textSecondary, lineHeight: 20 }}>
|
||||
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, lineHeight: 22 }}>
|
||||
{review.comment}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,17 +4,53 @@ import { useColorScheme } from "react-native";
|
||||
type ThemeColors = {
|
||||
primary: string;
|
||||
primaryLight: string;
|
||||
primaryDark: string;
|
||||
secondary: string;
|
||||
secondaryLight: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
surfaceAlt: string;
|
||||
card: string;
|
||||
border: string;
|
||||
borderLight: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
textTertiary: string;
|
||||
textInverse: string;
|
||||
error: string;
|
||||
errorLight: string;
|
||||
success: string;
|
||||
successLight: string;
|
||||
star: string;
|
||||
starLight: string;
|
||||
tabActive: string;
|
||||
tabInactive: string;
|
||||
};
|
||||
|
||||
type Shadow = {
|
||||
shadowColor: string;
|
||||
shadowOffset: { width: number; height: number };
|
||||
shadowOpacity: number;
|
||||
shadowRadius: number;
|
||||
elevation: number;
|
||||
};
|
||||
|
||||
type ThemeShadows = {
|
||||
sm: Shadow;
|
||||
md: Shadow;
|
||||
lg: Shadow;
|
||||
button: Shadow;
|
||||
};
|
||||
|
||||
type Typography = {
|
||||
h1: { fontSize: number; fontWeight: "700"; lineHeight: number };
|
||||
h2: { fontSize: number; fontWeight: "600"; lineHeight: number };
|
||||
h3: { fontSize: number; fontWeight: "600"; lineHeight: number };
|
||||
body: { fontSize: number; fontWeight: "400"; lineHeight: number };
|
||||
bodyBold: { fontSize: number; fontWeight: "600"; lineHeight: number };
|
||||
caption: { fontSize: number; fontWeight: "400"; lineHeight: number };
|
||||
captionBold: { fontSize: number; fontWeight: "600"; lineHeight: number };
|
||||
label: { fontSize: number; fontWeight: "500"; lineHeight: number };
|
||||
};
|
||||
|
||||
type Theme = {
|
||||
@ -25,56 +61,117 @@ type Theme = {
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
xxl: number;
|
||||
};
|
||||
radius: {
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
full: number;
|
||||
};
|
||||
shadows: ThemeShadows;
|
||||
typography: Typography;
|
||||
};
|
||||
|
||||
const makeShadow = (elevation: number, color: string): Shadow => {
|
||||
const shadows: Record<number, Shadow> = {
|
||||
2: { shadowColor: color, shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 3, elevation: 2 },
|
||||
4: { shadowColor: color, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.12, shadowRadius: 8, elevation: 4 },
|
||||
8: { shadowColor: color, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 16, elevation: 8 },
|
||||
};
|
||||
return shadows[elevation] || shadows[2];
|
||||
};
|
||||
|
||||
const spacing = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48 };
|
||||
const radius = { sm: 6, md: 12, lg: 16, xl: 24, full: 999 };
|
||||
|
||||
const typography: Typography = {
|
||||
h1: { fontSize: 28, fontWeight: "700", lineHeight: 36 },
|
||||
h2: { fontSize: 22, fontWeight: "600", lineHeight: 30 },
|
||||
h3: { fontSize: 18, fontWeight: "600", lineHeight: 24 },
|
||||
body: { fontSize: 15, fontWeight: "400", lineHeight: 22 },
|
||||
bodyBold: { fontSize: 15, fontWeight: "600", lineHeight: 22 },
|
||||
caption: { fontSize: 13, fontWeight: "400", lineHeight: 18 },
|
||||
captionBold: { fontSize: 13, fontWeight: "600", lineHeight: 18 },
|
||||
label: { fontSize: 11, fontWeight: "500", lineHeight: 16 },
|
||||
};
|
||||
|
||||
const LightTheme: Theme = {
|
||||
colors: {
|
||||
primary: "#2563EB",
|
||||
primaryLight: "#DBEAFE",
|
||||
secondary: "#059669",
|
||||
background: "#FFFFFF",
|
||||
surface: "#F9FAFB",
|
||||
primary: "#D4764A",
|
||||
primaryLight: "#FDF0EA",
|
||||
primaryDark: "#B85E34",
|
||||
secondary: "#1A2A3A",
|
||||
secondaryLight: "#E8ECF0",
|
||||
background: "#FDF8F3",
|
||||
surface: "#FFFFFF",
|
||||
surfaceAlt: "#F5F0EB",
|
||||
card: "#FFFFFF",
|
||||
border: "#E5E7EB",
|
||||
text: "#111827",
|
||||
textSecondary: "#6B7280",
|
||||
textTertiary: "#9CA3AF",
|
||||
border: "#E8E2DA",
|
||||
borderLight: "#F0EBE5",
|
||||
text: "#1A1A2E",
|
||||
textSecondary: "#8B7E74",
|
||||
textTertiary: "#B0A69C",
|
||||
textInverse: "#FFFFFF",
|
||||
error: "#DC2626",
|
||||
errorLight: "#FEE2E2",
|
||||
success: "#4A8C6F",
|
||||
successLight: "#E8F3EE",
|
||||
star: "#F59E0B",
|
||||
starLight: "#FEF3C7",
|
||||
tabActive: "#D4764A",
|
||||
tabInactive: "#B0A69C",
|
||||
},
|
||||
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
|
||||
radius: { sm: 6, md: 12, lg: 16, xl: 24 },
|
||||
spacing,
|
||||
radius,
|
||||
shadows: {
|
||||
sm: makeShadow(2, "#000000"),
|
||||
md: makeShadow(4, "#000000"),
|
||||
lg: makeShadow(8, "#000000"),
|
||||
button: { shadowColor: "#D4764A", shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.3, shadowRadius: 6, elevation: 4 },
|
||||
},
|
||||
typography,
|
||||
};
|
||||
|
||||
const DarkTheme: Theme = {
|
||||
colors: {
|
||||
primary: "#60A5FA",
|
||||
primaryLight: "#1E3A5F",
|
||||
secondary: "#34D399",
|
||||
background: "#111827",
|
||||
surface: "#1F2937",
|
||||
card: "#1F2937",
|
||||
border: "#374151",
|
||||
text: "#F9FAFB",
|
||||
primary: "#E89B78",
|
||||
primaryLight: "#2D1F18",
|
||||
primaryDark: "#D4764A",
|
||||
secondary: "#E8ECF0",
|
||||
secondaryLight: "#2A3040",
|
||||
background: "#0F0F1A",
|
||||
surface: "#1A1A2E",
|
||||
surfaceAlt: "#222240",
|
||||
card: "#1A1A2E",
|
||||
border: "#2A2A45",
|
||||
borderLight: "#222240",
|
||||
text: "#F0EDE8",
|
||||
textSecondary: "#9CA3AF",
|
||||
textTertiary: "#6B7280",
|
||||
textInverse: "#FFFFFF",
|
||||
error: "#F87171",
|
||||
errorLight: "#451111",
|
||||
success: "#6EC99B",
|
||||
successLight: "#1A3A2E",
|
||||
star: "#FBBF24",
|
||||
starLight: "#3A2E1A",
|
||||
tabActive: "#E89B78",
|
||||
tabInactive: "#6B7280",
|
||||
},
|
||||
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
|
||||
radius: { sm: 6, md: 12, lg: 16, xl: 24 },
|
||||
spacing,
|
||||
radius,
|
||||
shadows: {
|
||||
sm: makeShadow(2, "#000000"),
|
||||
md: makeShadow(4, "#000000"),
|
||||
lg: makeShadow(8, "#000000"),
|
||||
button: { shadowColor: "#E89B78", shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.3, shadowRadius: 6, elevation: 4 },
|
||||
},
|
||||
typography,
|
||||
};
|
||||
|
||||
export type { Theme };
|
||||
export type { Theme, ThemeColors, ThemeShadows, Typography };
|
||||
|
||||
const ThemeContext = createContext<Theme>(LightTheme);
|
||||
|
||||
@ -86,4 +183,4 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,28 +3,41 @@ import { useTheme } from "../theme";
|
||||
|
||||
interface BadgeProps {
|
||||
label: string;
|
||||
variant?: "default" | "primary" | "success" | "error";
|
||||
variant?: "default" | "primary" | "success" | "error" | "warning";
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function Badge({ label, variant = "default" }: BadgeProps) {
|
||||
export function Badge({ label, variant = "default", size = "md" }: BadgeProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
const variants = {
|
||||
default: { bg: theme.colors.surface, text: theme.colors.textSecondary },
|
||||
primary: { bg: theme.colors.primaryLight, text: theme.colors.primary },
|
||||
success: { bg: "#D1FAE5", text: "#059669" },
|
||||
error: { bg: theme.colors.errorLight, text: theme.colors.error },
|
||||
const styles = {
|
||||
default: { bg: theme.colors.surfaceAlt, text: theme.colors.textSecondary, border: theme.colors.borderLight },
|
||||
primary: { bg: theme.colors.primaryLight, text: theme.colors.primaryDark, border: theme.colors.primaryLight },
|
||||
success: { bg: theme.colors.successLight, text: theme.colors.success, border: theme.colors.successLight },
|
||||
error: { bg: theme.colors.errorLight, text: theme.colors.error, border: theme.colors.errorLight },
|
||||
warning: { bg: theme.colors.starLight, text: "#92400E", border: theme.colors.starLight },
|
||||
};
|
||||
|
||||
const { bg, text: textColor } = variants[variant];
|
||||
const { bg, text: textColor, border } = styles[variant];
|
||||
const isSmall = size === "sm";
|
||||
|
||||
return (
|
||||
<View
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
style={{ backgroundColor: bg, paddingHorizontal: 8, paddingVertical: 4, borderRadius: theme.radius.sm }}
|
||||
style={{
|
||||
backgroundColor: bg,
|
||||
paddingHorizontal: isSmall ? 6 : 10,
|
||||
paddingVertical: isSmall ? 3 : 5,
|
||||
borderRadius: theme.radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: border,
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: textColor, fontSize: 12, fontWeight: "600" }}>{label}</Text>
|
||||
<Text style={{ color: textColor, fontSize: isSmall ? 11 : 12, fontWeight: "600", letterSpacing: 0.2 }}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { Pressable, Text, ActivityIndicator, type ViewStyle } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
type Variant = "primary" | "secondary" | "outline" | "destructive";
|
||||
type Variant = "primary" | "secondary" | "outline" | "ghost" | "destructive";
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: Variant;
|
||||
loading?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
onPress?: () => void;
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
@ -19,6 +20,7 @@ export function Button({
|
||||
loading = false,
|
||||
size = "md",
|
||||
disabled = false,
|
||||
fullWidth = false,
|
||||
children,
|
||||
style,
|
||||
onPress,
|
||||
@ -27,22 +29,24 @@ export function Button({
|
||||
const theme = useTheme();
|
||||
|
||||
const sizeStyles: Record<string, ViewStyle> = {
|
||||
sm: { paddingVertical: theme.spacing.sm, paddingHorizontal: theme.spacing.md, borderRadius: theme.radius.sm },
|
||||
md: { paddingVertical: 12, paddingHorizontal: theme.spacing.lg, borderRadius: theme.radius.md },
|
||||
lg: { paddingVertical: 16, paddingHorizontal: theme.spacing.xl, borderRadius: theme.radius.md },
|
||||
sm: { paddingVertical: 8, paddingHorizontal: theme.spacing.md, borderRadius: theme.radius.sm },
|
||||
md: { paddingVertical: 14, paddingHorizontal: theme.spacing.lg, borderRadius: theme.radius.md },
|
||||
lg: { paddingVertical: 18, paddingHorizontal: theme.spacing.xl, borderRadius: theme.radius.lg },
|
||||
};
|
||||
|
||||
const variantStyles: Record<Variant, ViewStyle> = {
|
||||
primary: { backgroundColor: theme.colors.primary },
|
||||
secondary: { backgroundColor: theme.colors.secondary },
|
||||
outline: { backgroundColor: "transparent", borderWidth: 1, borderColor: theme.colors.primary },
|
||||
primary: { backgroundColor: theme.colors.primary, ...theme.shadows.button },
|
||||
secondary: { backgroundColor: theme.colors.surface, borderWidth: 1.5, borderColor: theme.colors.border },
|
||||
outline: { backgroundColor: "transparent", borderWidth: 1.5, borderColor: theme.colors.primary },
|
||||
ghost: { backgroundColor: "transparent" },
|
||||
destructive: { backgroundColor: theme.colors.error },
|
||||
};
|
||||
|
||||
const textColors: Record<Variant, string> = {
|
||||
primary: "#FFFFFF",
|
||||
secondary: "#FFFFFF",
|
||||
primary: theme.colors.textInverse,
|
||||
secondary: theme.colors.text,
|
||||
outline: theme.colors.primary,
|
||||
ghost: theme.colors.primary,
|
||||
destructive: "#FFFFFF",
|
||||
};
|
||||
|
||||
@ -57,17 +61,30 @@ export function Button({
|
||||
disabled={disabled || loading}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
{ alignItems: "center", justifyContent: "center" },
|
||||
{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
sizeStyles[size],
|
||||
variantStyles[variant],
|
||||
fullWidth && { width: "100%" },
|
||||
disabled && { opacity: 0.5 },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={variant === "outline" ? theme.colors.primary : "#FFFFFF"} size="small" />
|
||||
<ActivityIndicator color={textColors[variant]} size="small" />
|
||||
) : typeof children === "string" ? (
|
||||
<Text style={{ color: textColors[variant], fontWeight: "600", fontSize: size === "sm" ? 14 : 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
color: textColors[variant],
|
||||
fontWeight: "600",
|
||||
fontSize: size === "sm" ? 14 : 16,
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
@ -75,4 +92,4 @@ export function Button({
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +1,53 @@
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import { View, Pressable, type ViewStyle } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
onPress?: () => void;
|
||||
variant?: "default" | "elevated" | "outlined";
|
||||
}
|
||||
|
||||
export function Card({ children, style, onPress }: CardProps) {
|
||||
export function Card({ children, style, onPress, variant = "default" }: CardProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
const variantStyles: Record<string, ViewStyle> = {
|
||||
default: {
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.radius.lg,
|
||||
padding: theme.spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderLight,
|
||||
},
|
||||
elevated: {
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.radius.lg,
|
||||
padding: theme.spacing.md,
|
||||
...theme.shadows.md,
|
||||
},
|
||||
outlined: {
|
||||
backgroundColor: "transparent",
|
||||
borderRadius: theme.radius.lg,
|
||||
padding: theme.spacing.md,
|
||||
borderWidth: 1.5,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
};
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[variantStyles[variant], style]}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.radius.lg,
|
||||
padding: theme.spacing.md,
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View style={[variantStyles[variant], style]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,34 +1,48 @@
|
||||
import { TextInput, type TextInputProps, type ViewStyle } from "react-native";
|
||||
import { View, TextInput, Text, type TextInputProps, type ViewStyle } from "react-native";
|
||||
import { useTheme } from "../theme";
|
||||
|
||||
interface InputProps extends TextInputProps {
|
||||
error?: string;
|
||||
label?: string;
|
||||
containerStyle?: ViewStyle;
|
||||
}
|
||||
|
||||
export function Input({ error, style, ...props }: InputProps) {
|
||||
export function Input({ error, label, containerStyle, style, ...props }: InputProps) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
accessible
|
||||
accessibilityLabel={props.placeholder || "Внесете текст"}
|
||||
accessibilityRole="none"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: error ? theme.colors.error : theme.colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
fontSize: 16,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
error && { borderColor: theme.colors.error },
|
||||
typeof style === "object" ? style : undefined,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
<View style={[{ gap: theme.spacing.xs }, containerStyle]}>
|
||||
{label && (
|
||||
<Text style={{ color: theme.colors.textSecondary, ...theme.typography.captionBold }}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<TextInput
|
||||
accessible
|
||||
accessibilityLabel={label || props.placeholder || "Внесете текст"}
|
||||
accessibilityRole="none"
|
||||
placeholderTextColor={theme.colors.textTertiary}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderColor: error ? theme.colors.error : theme.colors.border,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: theme.radius.md,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
fontSize: 16,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
error && { borderColor: theme.colors.error },
|
||||
typeof style === "object" ? style : undefined,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<Text style={{ color: theme.colors.error, fontSize: 12, marginTop: 2 }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
25
convex/tsconfig.json
Normal file
25
convex/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
/* This TypeScript project config describes the environment that
|
||||
* Convex functions run in and is used to typecheck them.
|
||||
* You can modify it, but some settings are required to use Convex.
|
||||
*/
|
||||
"compilerOptions": {
|
||||
/* These settings are not required by Convex and can be modified. */
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
/* These compiler options are required by Convex */
|
||||
"target": "ESNext",
|
||||
"lib": ["ES2023", "dom"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*"],
|
||||
"exclude": ["./_generated"]
|
||||
}
|
||||
46
package-lock.json
generated
46
package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"convex": "^1.24.0",
|
||||
"expo": "~55.0.26",
|
||||
"expo-constants": "~55.0.16",
|
||||
@ -3214,6 +3215,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native-async-storage/async-storage": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"merge-options": "^3.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.83.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.6.tgz",
|
||||
@ -5888,6 +5901,15 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-obj": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
|
||||
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
@ -6352,9 +6374,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -6375,9 +6394,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -6398,9 +6414,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -6421,9 +6434,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -6626,6 +6636,18 @@
|
||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-options": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
|
||||
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-plain-obj": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"convex": "^1.24.0",
|
||||
"expo": "~55.0.26",
|
||||
"expo-constants": "~55.0.16",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user