checkpoint
This commit is contained in:
parent
8662037776
commit
e8f816916d
17
AGENTS.md
17
AGENTS.md
@ -7,7 +7,7 @@
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend:** Expo SDK 56, React Native 0.85, TypeScript, Expo Router (file-based routing), inline styles (no NativeWind/Tailwind)
|
- **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)
|
- **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
|
- **Deployment:** EAS Build + Update
|
||||||
|
|
||||||
@ -18,7 +18,17 @@
|
|||||||
- Self-deployed Convex does **not** support `@convex-dev/auth` — custom auth via `convex/auth.ts`
|
- 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`
|
- 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
|
- **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
|
- **Role system:** `handyman` | `customer` — set at registration. Handymen create ads; customers create posts; both can chat
|
||||||
@ -56,8 +66,9 @@ app/
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx expo start # Dev server
|
npx expo start # Dev server
|
||||||
|
npx expo start --dev-client # Dev server with dev client
|
||||||
npm run convex:dev # Convex backend dev
|
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
|
npx tsc --noEmit # Type check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
14
CLAUDE.md
14
CLAUDE.md
@ -1 +1,15 @@
|
|||||||
@AGENTS.md
|
@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,53 +51,83 @@ export default function LoginScreen() {
|
|||||||
style={{ flex: 1, backgroundColor: theme.colors.background }}
|
style={{ flex: 1, backgroundColor: theme.colors.background }}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentContainerStyle={{ flexGrow: 1, justifyContent: "center", padding: theme.spacing.lg }}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<View style={{ marginTop: 60, marginBottom: theme.spacing.xl }}>
|
<View
|
||||||
<Text style={{ fontSize: 32, fontWeight: "700", color: theme.colors.text }}>
|
style={{
|
||||||
МојМајстор
|
backgroundColor: theme.colors.card,
|
||||||
</Text>
|
borderRadius: theme.radius.xl,
|
||||||
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
|
padding: theme.spacing.lg,
|
||||||
Најдете мајстор во вашата близина
|
borderWidth: 1,
|
||||||
</Text>
|
borderColor: theme.colors.borderLight,
|
||||||
</View>
|
...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
|
<Input
|
||||||
placeholder="Е-пошта"
|
placeholder="Е-пошта"
|
||||||
value={email}
|
value={email}
|
||||||
onChangeText={setEmail}
|
onChangeText={setEmail}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
accessibilityLabel="Е-пошта"
|
accessibilityLabel="Е-пошта"
|
||||||
/>
|
label="Е-пошта"
|
||||||
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Лозинка"
|
placeholder="Лозинка"
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
accessibilityLabel="Лозинка"
|
accessibilityLabel="Лозинка"
|
||||||
/>
|
label="Лозинка"
|
||||||
|
/>
|
||||||
|
|
||||||
<View style={{ marginTop: theme.spacing.sm }}>
|
<Button
|
||||||
<Button loading={loading} onPress={handleLogin} accessibilityLabel="Најави се">
|
loading={loading}
|
||||||
|
onPress={handleLogin}
|
||||||
|
accessibilityLabel="Најави се"
|
||||||
|
fullWidth
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
Најави се
|
Најави се
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
|
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.sm }}>
|
||||||
<Text style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
|
<Text style={{ color: theme.colors.textSecondary }}>Немате профил? </Text>
|
||||||
<Text
|
<Text
|
||||||
accessible
|
accessible
|
||||||
accessibilityLabel="Регистрирајте се"
|
accessibilityLabel="Регистрирајте се"
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
style={{ color: theme.colors.primary, fontWeight: "600" }}
|
style={{ color: theme.colors.primary, fontWeight: "600" }}
|
||||||
onPress={() => router.push("/(auth)/register")}
|
onPress={() => router.push("/(auth)/register")}
|
||||||
>
|
>
|
||||||
Регистрирајте се
|
Регистрирајте се
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|||||||
@ -67,102 +67,161 @@ export default function RegisterScreen() {
|
|||||||
style={{ flex: 1, backgroundColor: theme.colors.background }}
|
style={{ flex: 1, backgroundColor: theme.colors.background }}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentContainerStyle={{ flexGrow: 1, padding: theme.spacing.lg }}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md }}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<View style={{ marginTop: 40, marginBottom: theme.spacing.md }}>
|
<View style={{
|
||||||
<Text style={{ fontSize: 28, fontWeight: "700", color: theme.colors.text }}>
|
backgroundColor: theme.colors.card,
|
||||||
Креирајте профил
|
borderRadius: theme.radius.xl,
|
||||||
</Text>
|
padding: theme.spacing.lg,
|
||||||
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
|
borderWidth: 1,
|
||||||
Изберете ја вашата улога
|
borderColor: theme.colors.borderLight,
|
||||||
</Text>
|
...theme.shadows.md,
|
||||||
</View>
|
gap: theme.spacing.md,
|
||||||
|
}}>
|
||||||
<View style={{ flexDirection: "row", gap: theme.spacing.md }}>
|
<View style={{ alignItems: "center", marginBottom: theme.spacing.sm }}>
|
||||||
<Pressable
|
<View
|
||||||
accessible
|
style={{
|
||||||
accessibilityLabel="Изберете улога: Клиент"
|
width: 64,
|
||||||
accessibilityRole="button"
|
height: 64,
|
||||||
onPress={() => setRole("customer")}
|
borderRadius: 32,
|
||||||
style={{
|
backgroundColor: theme.colors.primaryLight,
|
||||||
flex: 1,
|
alignItems: "center",
|
||||||
paddingVertical: theme.spacing.md,
|
justifyContent: "center",
|
||||||
paddingHorizontal: theme.spacing.sm,
|
marginBottom: theme.spacing.md,
|
||||||
borderRadius: theme.radius.md,
|
}}
|
||||||
borderWidth: 2,
|
>
|
||||||
borderColor: role === "customer" ? theme.colors.primary : theme.colors.border,
|
<Text style={{ fontSize: 32 }}>👤</Text>
|
||||||
backgroundColor: role === "customer" ? theme.colors.primaryLight : theme.colors.surface,
|
</View>
|
||||||
alignItems: "center",
|
<Text style={{ ...theme.typography.h1, color: theme.colors.text }}>
|
||||||
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>
|
</Text>
|
||||||
</Pressable>
|
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
|
||||||
|
Изберете ја вашата улога
|
||||||
<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>
|
</Text>
|
||||||
</Pressable>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<Input placeholder="Име и презиме" value={name} onChangeText={setName} accessibilityLabel="Име и презиме" />
|
<View style={{ flexDirection: "row", gap: theme.spacing.md }}>
|
||||||
<Input
|
<Pressable
|
||||||
placeholder="Е-пошта"
|
accessible
|
||||||
value={email}
|
accessibilityLabel="Изберете улога: Клиент"
|
||||||
onChangeText={setEmail}
|
accessibilityRole="button"
|
||||||
autoCapitalize="none"
|
onPress={() => setRole("customer")}
|
||||||
keyboardType="email-address"
|
style={{
|
||||||
accessibilityLabel="Е-пошта"
|
flex: 1,
|
||||||
/>
|
paddingVertical: theme.spacing.lg,
|
||||||
<Input
|
paddingHorizontal: theme.spacing.sm,
|
||||||
placeholder="Телефонски број (незадолжително)"
|
borderRadius: theme.radius.md,
|
||||||
value={phone}
|
borderWidth: 2,
|
||||||
onChangeText={setPhone}
|
borderColor: role === "customer" ? theme.colors.primary : theme.colors.borderLight,
|
||||||
keyboardType="phone-pad"
|
backgroundColor: role === "customer" ? theme.colors.primaryLight : theme.colors.surface,
|
||||||
accessibilityLabel="Телефонски број"
|
alignItems: "center",
|
||||||
/>
|
gap: theme.spacing.sm,
|
||||||
<Input placeholder="Лозинка (мин. 8 карактери)" value={password} onChangeText={setPassword} secureTextEntry accessibilityLabel="Лозинка" />
|
}}
|
||||||
|
>
|
||||||
|
<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 }}>
|
<Pressable
|
||||||
<Button loading={loading} onPress={handleRegister} accessibilityLabel="Регистрирај се">
|
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>
|
</Button>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.md }}>
|
<View style={{ flexDirection: "row", justifyContent: "center", marginTop: theme.spacing.sm }}>
|
||||||
<Text style={{ color: theme.colors.textSecondary }}>Веќе имате профил? </Text>
|
<Text style={{ color: theme.colors.textSecondary }}>Веќе имате профил? </Text>
|
||||||
<Text
|
<Text
|
||||||
accessible
|
accessible
|
||||||
accessibilityLabel="Најавете се"
|
accessibilityLabel="Најавете се"
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
style={{ color: theme.colors.primary, fontWeight: "600" }} onPress={() => router.back()}>
|
style={{ color: theme.colors.primary, fontWeight: "600" }}
|
||||||
Најавете се
|
onPress={() => router.back()}
|
||||||
</Text>
|
>
|
||||||
|
Најавете се
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|||||||
@ -39,16 +39,27 @@ export default function ExploreScreen() {
|
|||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
<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 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||||
<Link href="/(tabs)/(explore)" asChild>
|
<Link href="/(tabs)/(explore)" asChild>
|
||||||
<Pressable accessible accessibilityLabel="Назад кон сите категории" accessibilityRole="link" style={{ marginRight: theme.spacing.xs }}>
|
<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>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
<Text style={{ fontSize: 28 }}>{catEmoji}</Text>
|
<View
|
||||||
<Text selectable style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
|
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}
|
{catName}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -56,9 +67,12 @@ export default function ExploreScreen() {
|
|||||||
{categoryAdsResult.status === "LoadingFirstPage" ? (
|
{categoryAdsResult.status === "LoadingFirstPage" ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : categoryAdsResult.results.length === 0 ? (
|
) : categoryAdsResult.results.length === 0 ? (
|
||||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||||
Нема огласи во оваа категорија
|
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>🔎</Text>
|
||||||
</Text>
|
<Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
|
||||||
|
Нема огласи во оваа категорија
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
{categoryAdsResult.results.map((ad) => (
|
{categoryAdsResult.results.map((ad) => (
|
||||||
@ -74,15 +88,15 @@ export default function ExploreScreen() {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={() => categoryAdsResult.loadMore(20)}
|
onPress={() => categoryAdsResult.loadMore(20)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
paddingVertical: theme.spacing.md,
|
paddingVertical: theme.spacing.md,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
borderWidth: 1,
|
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>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@ -95,24 +109,27 @@ export default function ExploreScreen() {
|
|||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
<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>
|
</Text>
|
||||||
|
|
||||||
<CategoryGrid categories={displayCategories} />
|
<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>
|
</Text>
|
||||||
|
|
||||||
{allAdsResult.status === "LoadingFirstPage" ? (
|
{allAdsResult.status === "LoadingFirstPage" ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : allAdsResult.results.length === 0 ? (
|
) : allAdsResult.results.length === 0 ? (
|
||||||
<Text style={{ color: theme.colors.textTertiary, textAlign: "center", padding: 24 }}>
|
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||||
Нема огласи
|
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>📦</Text>
|
||||||
</Text>
|
<Text style={{ ...theme.typography.body, color: theme.colors.textTertiary, textAlign: "center" }}>
|
||||||
|
Нема огласи
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
{allAdsResult.results.map((ad) => (
|
{allAdsResult.results.map((ad) => (
|
||||||
@ -128,15 +145,15 @@ export default function ExploreScreen() {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={() => allAdsResult.loadMore(20)}
|
onPress={() => allAdsResult.loadMore(20)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
paddingVertical: theme.spacing.md,
|
paddingVertical: theme.spacing.md,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
borderWidth: 1,
|
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>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@ -1,15 +1,20 @@
|
|||||||
import { View, Text, TextInput, ScrollView, RefreshControl, Pressable } from "react-native";
|
import { View, Text, TextInput, ScrollView, RefreshControl, Pressable } from "react-native";
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useTheme } from "../../../components/theme";
|
import { useTheme } from "../../../components/theme";
|
||||||
import { useQuery, usePaginatedQuery } from "convex/react";
|
import { useQuery, usePaginatedQuery } from "convex/react";
|
||||||
import { api } from "../../../convex/_generated/api";
|
import { api } from "../../../convex/_generated/api";
|
||||||
|
import { useAuth } from "../../_layout";
|
||||||
import { CategoryGrid } from "../../../components/category-grid";
|
import { CategoryGrid } from "../../../components/category-grid";
|
||||||
import { AdCard } from "../../../components/ad-card";
|
import { AdCard } from "../../../components/ad-card";
|
||||||
import { Loading } from "../../../components/ui/loading";
|
import { Loading } from "../../../components/ui/loading";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { CATEGORIES } from "../../../lib/constants";
|
import { CATEGORIES } from "../../../lib/constants";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
const { userId } = useAuth();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
@ -40,81 +45,121 @@ export default function HomeScreen() {
|
|||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.colors.primary} />
|
<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 }}>
|
<View style={{ padding: theme.spacing.lg, gap: theme.spacing.md }}>
|
||||||
<Text style={{ fontSize: 24, fontWeight: "700", color: theme.colors.text }}>
|
<View style={{ marginTop: theme.spacing.md }}>
|
||||||
Добредојдовте 🏠
|
<Text style={{ ...theme.typography.h1, color: theme.colors.text }}>
|
||||||
</Text>
|
Добредојдовте
|
||||||
<Text selectable style={{ fontSize: 16, color: theme.colors.textSecondary, marginTop: theme.spacing.sm }}>
|
</Text>
|
||||||
Најдете мајстор во вашата близина
|
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
|
||||||
</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 }}>
|
|
||||||
Категории
|
|
||||||
</Text>
|
</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>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
{!search.trim() && adsStatus === "CanLoadMore" && (
|
<View
|
||||||
<Pressable
|
|
||||||
accessible
|
|
||||||
accessibilityLabel="Вчитај повеќе огласи"
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={() => loadMoreAds(20)}
|
|
||||||
style={{
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surface,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
paddingVertical: theme.spacing.md,
|
borderWidth: 1.5,
|
||||||
alignItems: "center",
|
borderColor: theme.colors.borderLight,
|
||||||
borderWidth: 1,
|
paddingHorizontal: theme.spacing.md,
|
||||||
borderColor: theme.colors.border,
|
gap: theme.spacing.sm,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ color: theme.colors.primary, fontWeight: "600", fontSize: 14 }}>Вчитај повеќе</Text>
|
<Text style={{ fontSize: 18 }}>🔍</Text>
|
||||||
</Pressable>
|
<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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -7,6 +7,7 @@ import { useAuth } from "../../_layout";
|
|||||||
import { useTheme } from "../../../components/theme";
|
import { useTheme } from "../../../components/theme";
|
||||||
import { PostCard } from "../../../components/post-card";
|
import { PostCard } from "../../../components/post-card";
|
||||||
import { Loading } from "../../../components/ui/loading";
|
import { Loading } from "../../../components/ui/loading";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: "open", label: "Отворени" },
|
{ key: "open", label: "Отворени" },
|
||||||
@ -48,29 +49,21 @@ export default function PostsScreen() {
|
|||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
||||||
>
|
>
|
||||||
{isAuthenticated && isCustomer && (
|
{isAuthenticated && isCustomer && (
|
||||||
<Pressable
|
<Button
|
||||||
accessible
|
|
||||||
accessibilityLabel="Креирај ново побарување"
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={() => router.push("/post/create" as any)}
|
onPress={() => router.push("/post/create" as any)}
|
||||||
|
size="lg"
|
||||||
|
accessibilityLabel="Креирај ново побарување"
|
||||||
>
|
>
|
||||||
<View
|
+ Ново побарување
|
||||||
style={{
|
</Button>
|
||||||
backgroundColor: theme.colors.primary,
|
|
||||||
borderRadius: theme.radius.md,
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: theme.spacing.lg,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#FFFFFF", fontWeight: "600", fontSize: 16 }}>
|
|
||||||
+ Ново побарување
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={{ flexDirection: "row", backgroundColor: theme.colors.surface, borderRadius: theme.radius.md, padding: 4 }}>
|
<View style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
|
borderRadius: theme.radius.md,
|
||||||
|
padding: 3,
|
||||||
|
}}>
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
@ -85,9 +78,7 @@ export default function PostsScreen() {
|
|||||||
borderRadius: theme.radius.sm,
|
borderRadius: theme.radius.sm,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: activeTab === tab.key ? theme.colors.card : "transparent",
|
backgroundColor: activeTab === tab.key ? theme.colors.card : "transparent",
|
||||||
...(activeTab === tab.key
|
...(activeTab === tab.key ? theme.shadows.sm : undefined),
|
||||||
? { boxShadow: "0 1px 3px rgba(0,0,0,0.08)" as any }
|
|
||||||
: {}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
@ -107,14 +98,14 @@ export default function PostsScreen() {
|
|||||||
<Loading />
|
<Loading />
|
||||||
) : postsResult.results.length === 0 ? (
|
) : postsResult.results.length === 0 ? (
|
||||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
||||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>📋</Text>
|
<Text style={{ fontSize: 48, marginBottom: theme.spacing.md }}>📋</Text>
|
||||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
<Text style={{ ...theme.typography.h3, color: theme.colors.text, marginBottom: theme.spacing.sm }}>
|
||||||
Нема побарувања
|
Нема побарувања
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ color: theme.colors.textSecondary, textAlign: "center" }}>
|
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||||
{activeTab === "open"
|
{activeTab === "open"
|
||||||
? "Нема отворени побарувања во моментот."
|
? "Нема отворени побарувања во моментот."
|
||||||
: "Сеуште нема побарувања."}
|
: "Сè уште нема побарувања."}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@ -132,15 +123,15 @@ export default function PostsScreen() {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={() => postsResult.loadMore(20)}
|
onPress={() => postsResult.loadMore(20)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
paddingVertical: theme.spacing.md,
|
paddingVertical: theme.spacing.md,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
borderWidth: 1,
|
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>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@ -7,13 +7,11 @@ import { useAuth } from "../../_layout";
|
|||||||
import { useTheme } from "../../../components/theme";
|
import { useTheme } from "../../../components/theme";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
import { Rating } from "../../../components/ui/rating";
|
|
||||||
import { Loading } from "../../../components/ui/loading";
|
import { Loading } from "../../../components/ui/loading";
|
||||||
import { Card } from "../../../components/ui/card";
|
import { Card } from "../../../components/ui/card";
|
||||||
import { Avatar } from "../../../components/ui/avatar";
|
import { Avatar } from "../../../components/ui/avatar";
|
||||||
import { AdCard } from "../../../components/ad-card";
|
import { AdCard } from "../../../components/ad-card";
|
||||||
import { PostCard } from "../../../components/post-card";
|
import { PostCard } from "../../../components/post-card";
|
||||||
import { getCategoryName } from "../../../lib/constants";
|
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -73,15 +71,23 @@ export default function ProfileScreen() {
|
|||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
|
<View style={{
|
||||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>👤</Text>
|
flex: 1,
|
||||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text, marginBottom: 8 }}>
|
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>
|
||||||
<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>
|
</Text>
|
||||||
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
<Button onPress={() => router.push("/(auth)/login")} size="lg">
|
||||||
|
Најави се
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -106,52 +112,55 @@ export default function ProfileScreen() {
|
|||||||
}
|
}
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 80 }}
|
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} />
|
<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 || "Корисник"}
|
{user.name || "Корисник"}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge label={isHandyman ? "Мајстор" : "Клиент"} variant={isHandyman ? "primary" : "default"} />
|
<Badge label={isHandyman ? "Мајстор" : "Клиент"} variant={isHandyman ? "primary" : "default"} />
|
||||||
{user.email && (
|
{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}
|
{user.email}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{user.phone && (
|
{user.phone && (
|
||||||
<Text selectable style={{ color: theme.colors.textSecondary, fontSize: 14 }}>
|
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary }}>
|
||||||
{user.phone}
|
{user.phone}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</Card>
|
||||||
|
|
||||||
{isHandyman && (
|
{isHandyman && (
|
||||||
<>
|
<>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
<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>
|
</Text>
|
||||||
<Pressable
|
<Button
|
||||||
accessible
|
size="sm"
|
||||||
accessibilityLabel="Креирај нов оглас"
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={() => router.push("/ad/create" as any)}
|
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>
|
</Button>
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{myAdsResult.status === "LoadingFirstPage" ? (
|
{myAdsResult.status === "LoadingFirstPage" ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : myAds.length === 0 ? (
|
) : myAds.length === 0 ? (
|
||||||
<Card>
|
<Card style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
|
||||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
<Text style={{ fontSize: 48, marginBottom: theme.spacing.sm }}>📋</Text>
|
||||||
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📋</Text>
|
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||||
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
|
Сè уште немате огласи.
|
||||||
Сеуште немате огласи. Креирајте нов оглас за да се прикажете на клиентите.
|
</Text>
|
||||||
</Text>
|
<Button
|
||||||
</View>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => router.push("/ad/create" as any)}
|
||||||
|
style={{ marginTop: theme.spacing.md }}
|
||||||
|
>
|
||||||
|
Креирај оглас
|
||||||
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
@ -171,7 +180,7 @@ export default function ProfileScreen() {
|
|||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
borderWidth: 1,
|
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: 14 }}>Уреди</Text>
|
||||||
@ -204,15 +213,15 @@ export default function ProfileScreen() {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={() => myAdsResult.loadMore(20)}
|
onPress={() => myAdsResult.loadMore(20)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
paddingVertical: theme.spacing.md,
|
paddingVertical: theme.spacing.md,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
borderWidth: 1,
|
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>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -221,31 +230,34 @@ export default function ProfileScreen() {
|
|||||||
{!isHandyman && (
|
{!isHandyman && (
|
||||||
<>
|
<>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
<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>
|
</Text>
|
||||||
<Pressable
|
<Button
|
||||||
accessible
|
size="sm"
|
||||||
accessibilityLabel="Креирај ново побарување"
|
|
||||||
accessibilityRole="button"
|
|
||||||
onPress={() => router.push("/post/create" as any)}
|
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>
|
</Button>
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{myPostsResult.status === "LoadingFirstPage" ? (
|
{myPostsResult.status === "LoadingFirstPage" ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : myPosts.length === 0 ? (
|
) : myPosts.length === 0 ? (
|
||||||
<Card>
|
<Card style={{ alignItems: "center", paddingVertical: theme.spacing.xl }}>
|
||||||
<View style={{ alignItems: "center", paddingVertical: theme.spacing.lg }}>
|
<Text style={{ fontSize: 48, marginBottom: theme.spacing.sm }}>📝</Text>
|
||||||
<Text style={{ fontSize: 40, marginBottom: theme.spacing.sm }}>📝</Text>
|
<Text selectable style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||||
<Text selectable style={{ color: theme.colors.textSecondary, textAlign: "center", fontSize: 15 }}>
|
Сè уште немате побарувања.
|
||||||
Сеуште немате побарувања. Креирајте ново побарување за да најдете мајстор.
|
</Text>
|
||||||
</Text>
|
<Button
|
||||||
</View>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => router.push("/post/create" as any)}
|
||||||
|
style={{ marginTop: theme.spacing.md }}
|
||||||
|
>
|
||||||
|
Креирај побарување
|
||||||
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
@ -262,30 +274,41 @@ export default function ProfileScreen() {
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={() => myPostsResult.loadMore(20)}
|
onPress={() => myPostsResult.loadMore(20)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
paddingVertical: theme.spacing.md,
|
paddingVertical: theme.spacing.md,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
borderWidth: 1,
|
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>
|
</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 }}>
|
<Card>
|
||||||
<Pressable accessible accessibilityLabel="Оцени" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
|
<Pressable
|
||||||
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Оцени</Text>
|
accessible
|
||||||
|
accessibilityLabel="Поставки"
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={{ paddingVertical: theme.spacing.sm }}
|
||||||
|
>
|
||||||
|
<Text style={{ ...theme.typography.body, color: theme.colors.text }}>Поставки</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable accessible accessibilityLabel="Поставки" accessibilityRole="button" style={{ paddingVertical: theme.spacing.sm }}>
|
<View style={{ height: 1, backgroundColor: theme.colors.borderLight, marginVertical: theme.spacing.xs }} />
|
||||||
<Text style={{ color: theme.colors.text, fontSize: 16 }}>Поставки</Text>
|
<Pressable
|
||||||
|
accessible
|
||||||
|
accessibilityLabel="Оцени"
|
||||||
|
accessibilityRole="button"
|
||||||
|
style={{ paddingVertical: theme.spacing.sm }}
|
||||||
|
>
|
||||||
|
<Text style={{ ...theme.typography.body, color: theme.colors.text }}>Оцени ја апликацијата</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</Card>
|
||||||
|
|
||||||
<Button variant="destructive" onPress={handleLogout}>
|
<Button variant="destructive" onPress={handleLogout} fullWidth>
|
||||||
Одјави се
|
Одјави се
|
||||||
</Button>
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@ -1,26 +1,35 @@
|
|||||||
import { Tabs } from "expo-router";
|
import { Tabs } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useTheme } from "../../components/theme";
|
import { useTheme } from "../../components/theme";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarActiveTintColor: theme.colors.primary,
|
tabBarActiveTintColor: theme.colors.tabActive,
|
||||||
tabBarInactiveTintColor: theme.colors.textTertiary,
|
tabBarInactiveTintColor: theme.colors.tabInactive,
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: theme.colors.card,
|
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
|
<Tabs.Screen
|
||||||
name="(home)"
|
name="(home)"
|
||||||
options={{
|
options={{
|
||||||
title: "Почетна",
|
title: "Почетна",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
|
||||||
tabBarAccessibilityLabel: "Почетна",
|
tabBarAccessibilityLabel: "Почетна",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -28,6 +37,7 @@ export default function TabLayout() {
|
|||||||
name="(explore)"
|
name="(explore)"
|
||||||
options={{
|
options={{
|
||||||
title: "Пребарување",
|
title: "Пребарување",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="search-outline" size={size} color={color} />,
|
||||||
tabBarAccessibilityLabel: "Пребарување",
|
tabBarAccessibilityLabel: "Пребарување",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -35,6 +45,7 @@ export default function TabLayout() {
|
|||||||
name="(posts)"
|
name="(posts)"
|
||||||
options={{
|
options={{
|
||||||
title: "Побарувања",
|
title: "Побарувања",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="document-text-outline" size={size} color={color} />,
|
||||||
tabBarAccessibilityLabel: "Побарувања",
|
tabBarAccessibilityLabel: "Побарувања",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -42,6 +53,7 @@ export default function TabLayout() {
|
|||||||
name="(chat)"
|
name="(chat)"
|
||||||
options={{
|
options={{
|
||||||
title: "Чат",
|
title: "Чат",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubble-outline" size={size} color={color} />,
|
||||||
tabBarAccessibilityLabel: "Чат",
|
tabBarAccessibilityLabel: "Чат",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -49,6 +61,7 @@ export default function TabLayout() {
|
|||||||
name="(profile)"
|
name="(profile)"
|
||||||
options={{
|
options={{
|
||||||
title: "Профил",
|
title: "Профил",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="person-outline" size={size} color={color} />,
|
||||||
tabBarAccessibilityLabel: "Профил",
|
tabBarAccessibilityLabel: "Профил",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Stack } from "expo-router/stack";
|
|||||||
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||||||
import { useState, createContext, useContext, useEffect, useCallback, Component, type ReactNode, type ErrorInfo } from "react";
|
import { useState, createContext, useContext, useEffect, useCallback, Component, type ReactNode, type ErrorInfo } from "react";
|
||||||
import { View, Text, Pressable, ScrollView } from "react-native";
|
import { View, Text, Pressable, ScrollView } from "react-native";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { ThemeProvider, useTheme } from "../components/theme";
|
import { ThemeProvider, useTheme } from "../components/theme";
|
||||||
|
|
||||||
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
|
||||||
@ -86,8 +87,8 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
localStorage.getItem(TOKEN_KEY),
|
AsyncStorage.getItem(TOKEN_KEY),
|
||||||
localStorage.getItem(USER_ID_KEY),
|
AsyncStorage.getItem(USER_ID_KEY),
|
||||||
]).then(([t, u]) => {
|
]).then(([t, u]) => {
|
||||||
if (t) setToken(t);
|
if (t) setToken(t);
|
||||||
if (u) setUserId(u);
|
if (u) setUserId(u);
|
||||||
@ -96,15 +97,15 @@ export default function RootLayout() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = useCallback(async (newToken: string, newUserId: string) => {
|
const login = useCallback(async (newToken: string, newUserId: string) => {
|
||||||
localStorage.setItem(TOKEN_KEY, newToken);
|
await AsyncStorage.setItem(TOKEN_KEY, newToken);
|
||||||
localStorage.setItem(USER_ID_KEY, newUserId);
|
await AsyncStorage.setItem(USER_ID_KEY, newUserId);
|
||||||
setToken(newToken);
|
setToken(newToken);
|
||||||
setUserId(newUserId);
|
setUserId(newUserId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
await AsyncStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem(USER_ID_KEY);
|
await AsyncStorage.removeItem(USER_ID_KEY);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
setUserId(null);
|
setUserId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
110
app/ad/[id].tsx
110
app/ad/[id].tsx
@ -143,54 +143,85 @@ export default function AdDetailScreen() {
|
|||||||
accessible
|
accessible
|
||||||
accessibilityLabel={`Слика за ${ad.title}`}
|
accessibilityLabel={`Слика за ${ad.title}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
borderRadius: theme.radius.lg,
|
borderRadius: theme.radius.lg,
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderLight,
|
||||||
borderStyle: "dashed",
|
borderStyle: "dashed",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 48 }}>{categoryEmoji}</Text>
|
<View
|
||||||
<Text style={{ color: theme.colors.textTertiary, marginTop: theme.spacing.sm, fontSize: 14 }}>
|
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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View style={{ gap: theme.spacing.xs }}>
|
||||||
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
{ad.priceRange && (
|
<View style={{ flex: 1 }}>
|
||||||
<Text selectable style={{ fontSize: 18, fontWeight: "600", color: theme.colors.primary, marginTop: theme.spacing.xs }}>
|
<Text style={{ ...theme.typography.h2, color: theme.colors.text }}>{ad.title}</Text>
|
||||||
{ad.priceRange}
|
</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>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
||||||
|
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={18} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</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 && (
|
{ad.availability && (
|
||||||
<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>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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>
|
||||||
<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>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@ -205,24 +236,33 @@ export default function AdDetailScreen() {
|
|||||||
>
|
>
|
||||||
<Avatar uri={handyman?.avatarId ?? null} name={handyman?.name || "Мајстор"} size={48} />
|
<Avatar uri={handyman?.avatarId ?? null} name={handyman?.name || "Мајстор"} size={48} />
|
||||||
<View style={{ flex: 1 }}>
|
<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 || "Мајстор"}
|
{handyman?.name || "Мајстор"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 13, color: theme.colors.textSecondary }}>Мајстор</Text>
|
<Text style={{ ...theme.typography.caption, color: theme.colors.textSecondary }}>
|
||||||
|
Мајстор
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<Text style={{ color: theme.colors.primary, fontSize: 20 }}>›</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{!isOwner && isAuthenticated && (
|
{!isOwner && isAuthenticated && (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
<Button loading={startingChat} onPress={handleStartChat}>Започни разговор</Button>
|
<Button loading={startingChat} onPress={handleStartChat} size="lg" fullWidth>
|
||||||
|
Започни разговор
|
||||||
|
</Button>
|
||||||
{!existingUserReview && (
|
{!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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{existingUserReview && (
|
{existingUserReview && (
|
||||||
<Text style={{ fontSize: 13, color: theme.colors.textTertiary, textAlign: "center" }}>
|
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary, textAlign: "center" }}>
|
||||||
Веќе напишавте оценка за овој оглас
|
Веќе напишавте оценка за овој оглас
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -234,10 +274,11 @@ export default function AdDetailScreen() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onPress={() => router.push(`/ad/create?editId=${ad._id}` as any)}
|
onPress={() => router.push(`/ad/create?editId=${ad._id}` as any)}
|
||||||
|
fullWidth
|
||||||
>
|
>
|
||||||
Уреди оглас
|
Уреди оглас
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onPress={handleDelete}>
|
<Button variant="destructive" onPress={handleDelete} fullWidth>
|
||||||
Избриши оглас
|
Избриши оглас
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@ -245,27 +286,28 @@ export default function AdDetailScreen() {
|
|||||||
|
|
||||||
{!isOwner && !isAuthenticated && (
|
{!isOwner && !isAuthenticated && (
|
||||||
<Card>
|
<Card>
|
||||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, textAlign: "center" }}>
|
<Text style={{ ...theme.typography.body, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||||
Најавете се за да започнете разговор или да напишете оценка.
|
Најавете се за да започнете разговор или да напишете оценка.
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ marginTop: theme.spacing.md }}>
|
<View style={{ marginTop: theme.spacing.md }}>
|
||||||
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
<Button onPress={() => router.push("/(auth)/login")} fullWidth>
|
||||||
|
Најави се
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Reviews section */}
|
|
||||||
{reviews && reviews.length > 0 && (
|
{reviews && reviews.length > 0 && (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
<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>
|
</Text>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.xs }}>
|
<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)}
|
{ad.ratingAvg?.toFixed(1)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 14, color: theme.colors.textTertiary }}>
|
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary }}>
|
||||||
({ad.reviewCount} {ad.reviewCount === 1 ? "оценка" : "оцени"})
|
({ad.reviewCount} {ad.reviewCount === 1 ? "оценка" : "оцени"})
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Link } from "expo-router";
|
|||||||
import { useTheme } from "./theme";
|
import { useTheme } from "./theme";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Rating } from "./ui/rating";
|
import { Rating } from "./ui/rating";
|
||||||
import { getCategoryName } from "../lib/constants";
|
import { getCategoryName, CATEGORY_EMOJI } from "../lib/constants";
|
||||||
|
|
||||||
interface AdCardProps {
|
interface AdCardProps {
|
||||||
ad: {
|
ad: {
|
||||||
@ -20,6 +20,7 @@ interface AdCardProps {
|
|||||||
export function AdCard({ ad }: AdCardProps) {
|
export function AdCard({ ad }: AdCardProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const categoryName = getCategoryName(ad.category);
|
const categoryName = getCategoryName(ad.category);
|
||||||
|
const emoji = CATEGORY_EMOJI[ad.category] ?? "🔧";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/ad/${ad._id}` as any} asChild>
|
<Link href={`/ad/${ad._id}` as any} asChild>
|
||||||
@ -30,31 +31,56 @@ export function AdCard({ ad }: AdCardProps) {
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
borderRadius: theme.radius.lg,
|
borderRadius: theme.radius.lg,
|
||||||
padding: theme.spacing.md,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderLight,
|
||||||
gap: theme.spacing.sm,
|
...theme.shadows.sm,
|
||||||
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
<View style={{ padding: theme.spacing.md, gap: theme.spacing.sm }}>
|
||||||
<View style={{ flex: 1, gap: 2 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||||
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
|
<View
|
||||||
{ad.title}
|
style={{
|
||||||
</Text>
|
width: 40,
|
||||||
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
height: 40,
|
||||||
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={14} />
|
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>
|
</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" }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||||
<Badge label={categoryName} variant="primary" />
|
<Badge label={categoryName} variant="primary" size="sm" />
|
||||||
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {ad.location}</Text>
|
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary }}>
|
||||||
|
📍 {ad.location}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -15,6 +15,13 @@ interface CategoryGridProps {
|
|||||||
columns?: number;
|
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) {
|
export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
@ -22,6 +29,7 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
|
|||||||
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: theme.spacing.sm }}>
|
||||||
{categories.map((cat) => {
|
{categories.map((cat) => {
|
||||||
const emoji = CATEGORY_EMOJI[cat.slug] ?? cat.icon ?? "📋";
|
const emoji = CATEGORY_EMOJI[cat.slug] ?? cat.icon ?? "📋";
|
||||||
|
const color = getCategoryColor(cat.slug);
|
||||||
return (
|
return (
|
||||||
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
|
<Link key={cat.slug} href={`/(tabs)/(explore)?category=${cat.slug}`} asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
@ -32,22 +40,35 @@ export function CategoryGrid({ categories, columns = 3 }: CategoryGridProps) {
|
|||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surface,
|
||||||
borderRadius: theme.radius.md,
|
borderRadius: theme.radius.md,
|
||||||
paddingVertical: theme.spacing.md,
|
paddingVertical: theme.spacing.md,
|
||||||
paddingHorizontal: theme.spacing.sm,
|
paddingHorizontal: theme.spacing.xs,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderLight,
|
||||||
width: `${Math.floor((100 - (columns - 1) * 3.3) / columns)}%` as any,
|
width: `${Math.floor((100 - (columns - 1) * 3.3) / columns)}%` as any,
|
||||||
alignItems: "center",
|
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
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: theme.colors.text,
|
color: theme.colors.text,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: "500",
|
fontWeight: "600",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
lineHeight: 16,
|
||||||
}}
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@ -58,42 +58,28 @@ export function ChatListItem({ chat, currentUserId }: ChatListItemProps) {
|
|||||||
paddingVertical: theme.spacing.md,
|
paddingVertical: theme.spacing.md,
|
||||||
paddingHorizontal: theme.spacing.lg,
|
paddingHorizontal: theme.spacing.lg,
|
||||||
gap: theme.spacing.md,
|
gap: theme.spacing.md,
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.borderLight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
uri={chat.otherUser?.avatarId ?? null}
|
uri={chat.otherUser?.avatarId ?? null}
|
||||||
name={chat.otherUser?.name ?? "Непознат"}
|
name={chat.otherUser?.name ?? "Непознат"}
|
||||||
size={50}
|
size={52}
|
||||||
/>
|
/>
|
||||||
<View style={{ flex: 1, gap: 2 }}>
|
<View style={{ flex: 1, gap: 3 }}>
|
||||||
<View
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
style={{
|
<Text style={{ ...theme.typography.bodyBold, color: theme.colors.text, flex: 1 }} numberOfLines={1}>
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text, flex: 1 }}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{chat.otherUser?.name ?? "Непознат"}
|
{chat.otherUser?.name ?? "Непознат"}
|
||||||
</Text>
|
</Text>
|
||||||
{chat.lastMessage && (
|
{chat.lastMessage && (
|
||||||
<Text
|
<Text style={{ ...theme.typography.label, color: theme.colors.textTertiary, marginLeft: 8 }}>
|
||||||
style={{ fontSize: 12, color: theme.colors.textTertiary, marginLeft: 8 }}
|
|
||||||
>
|
|
||||||
{formatRelativeTime(chat.lastMessage.createdAt)}
|
{formatRelativeTime(chat.lastMessage.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text style={{ ...theme.typography.caption, color: theme.colors.textSecondary }} numberOfLines={1}>
|
||||||
style={{ fontSize: 14, color: theme.colors.textSecondary }}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{previewText}
|
{previewText}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { View, Text, Pressable } from "react-native";
|
|||||||
import { Link } from "expo-router";
|
import { Link } from "expo-router";
|
||||||
import { useTheme } from "./theme";
|
import { useTheme } from "./theme";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { getCategoryName } from "../lib/constants";
|
import { getCategoryName, CATEGORY_EMOJI } from "../lib/constants";
|
||||||
|
|
||||||
interface PostCardProps {
|
interface PostCardProps {
|
||||||
post: {
|
post: {
|
||||||
@ -20,6 +20,7 @@ interface PostCardProps {
|
|||||||
export function PostCard({ post }: PostCardProps) {
|
export function PostCard({ post }: PostCardProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const categoryName = post.category ? getCategoryName(post.category) : null;
|
const categoryName = post.category ? getCategoryName(post.category) : null;
|
||||||
|
const emoji = post.category ? (CATEGORY_EMOJI[post.category] ?? "📋") : "📋";
|
||||||
const isOpen = post.status === "open";
|
const isOpen = post.status === "open";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -31,33 +32,54 @@ export function PostCard({ post }: PostCardProps) {
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
borderRadius: theme.radius.lg,
|
borderRadius: theme.radius.lg,
|
||||||
padding: theme.spacing.md,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderLight,
|
||||||
gap: theme.spacing.sm,
|
...theme.shadows.sm,
|
||||||
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
<View style={{
|
||||||
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text, flex: 1 }} numberOfLines={2}>
|
padding: theme.spacing.md,
|
||||||
{post.title}
|
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>
|
</Text>
|
||||||
<Badge label={isOpen ? "Отворено" : "Затворено"} variant={isOpen ? "success" : "error"} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }} numberOfLines={2}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||||
{post.description}
|
{categoryName && <Badge label={categoryName} variant="primary" size="sm" />}
|
||||||
</Text>
|
{post.location && (
|
||||||
|
<Text style={{ ...theme.typography.caption, color: theme.colors.textTertiary }}>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
📍 {post.location}
|
||||||
{categoryName && <Badge label={categoryName} variant="primary" />}
|
</Text>
|
||||||
{post.location && (
|
)}
|
||||||
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {post.location}</Text>
|
{post.budget && (
|
||||||
)}
|
<Text selectable style={{ ...theme.typography.captionBold, color: theme.colors.primary }}>
|
||||||
{post.budget && (
|
💰 {post.budget}
|
||||||
<Text selectable style={{ fontSize: 13, color: theme.colors.primary, fontWeight: "600" }}>
|
</Text>
|
||||||
💰 {post.budget}
|
)}
|
||||||
</Text>
|
</View>
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -45,25 +45,25 @@ export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCar
|
|||||||
borderRadius: theme.radius.lg,
|
borderRadius: theme.radius.lg,
|
||||||
padding: theme.spacing.md,
|
padding: theme.spacing.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderLight,
|
||||||
gap: theme.spacing.sm,
|
gap: theme.spacing.sm,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||||
<Avatar uri={customerAvatarId ?? null} name={customerName} size={36} />
|
<Avatar uri={customerAvatarId ?? null} name={customerName} size={36} />
|
||||||
<View style={{ flex: 1 }}>
|
<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}
|
{customerName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 12, color: theme.colors.textTertiary }}>
|
<Text style={{ ...theme.typography.label, color: theme.colors.textTertiary }}>
|
||||||
{formatTimeAgo(review.createdAt)}
|
{formatTimeAgo(review.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Rating value={review.rating} size={16} />
|
<Rating value={review.rating} size={14} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{review.comment ? (
|
{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}
|
{review.comment}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -4,17 +4,53 @@ import { useColorScheme } from "react-native";
|
|||||||
type ThemeColors = {
|
type ThemeColors = {
|
||||||
primary: string;
|
primary: string;
|
||||||
primaryLight: string;
|
primaryLight: string;
|
||||||
|
primaryDark: string;
|
||||||
secondary: string;
|
secondary: string;
|
||||||
|
secondaryLight: string;
|
||||||
background: string;
|
background: string;
|
||||||
surface: string;
|
surface: string;
|
||||||
|
surfaceAlt: string;
|
||||||
card: string;
|
card: string;
|
||||||
border: string;
|
border: string;
|
||||||
|
borderLight: string;
|
||||||
text: string;
|
text: string;
|
||||||
textSecondary: string;
|
textSecondary: string;
|
||||||
textTertiary: string;
|
textTertiary: string;
|
||||||
|
textInverse: string;
|
||||||
error: string;
|
error: string;
|
||||||
errorLight: string;
|
errorLight: string;
|
||||||
|
success: string;
|
||||||
|
successLight: string;
|
||||||
star: 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 = {
|
type Theme = {
|
||||||
@ -25,56 +61,117 @@ type Theme = {
|
|||||||
md: number;
|
md: number;
|
||||||
lg: number;
|
lg: number;
|
||||||
xl: number;
|
xl: number;
|
||||||
|
xxl: number;
|
||||||
};
|
};
|
||||||
radius: {
|
radius: {
|
||||||
sm: number;
|
sm: number;
|
||||||
md: number;
|
md: number;
|
||||||
lg: number;
|
lg: number;
|
||||||
xl: 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 = {
|
const LightTheme: Theme = {
|
||||||
colors: {
|
colors: {
|
||||||
primary: "#2563EB",
|
primary: "#D4764A",
|
||||||
primaryLight: "#DBEAFE",
|
primaryLight: "#FDF0EA",
|
||||||
secondary: "#059669",
|
primaryDark: "#B85E34",
|
||||||
background: "#FFFFFF",
|
secondary: "#1A2A3A",
|
||||||
surface: "#F9FAFB",
|
secondaryLight: "#E8ECF0",
|
||||||
|
background: "#FDF8F3",
|
||||||
|
surface: "#FFFFFF",
|
||||||
|
surfaceAlt: "#F5F0EB",
|
||||||
card: "#FFFFFF",
|
card: "#FFFFFF",
|
||||||
border: "#E5E7EB",
|
border: "#E8E2DA",
|
||||||
text: "#111827",
|
borderLight: "#F0EBE5",
|
||||||
textSecondary: "#6B7280",
|
text: "#1A1A2E",
|
||||||
textTertiary: "#9CA3AF",
|
textSecondary: "#8B7E74",
|
||||||
|
textTertiary: "#B0A69C",
|
||||||
|
textInverse: "#FFFFFF",
|
||||||
error: "#DC2626",
|
error: "#DC2626",
|
||||||
errorLight: "#FEE2E2",
|
errorLight: "#FEE2E2",
|
||||||
|
success: "#4A8C6F",
|
||||||
|
successLight: "#E8F3EE",
|
||||||
star: "#F59E0B",
|
star: "#F59E0B",
|
||||||
|
starLight: "#FEF3C7",
|
||||||
|
tabActive: "#D4764A",
|
||||||
|
tabInactive: "#B0A69C",
|
||||||
},
|
},
|
||||||
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
|
spacing,
|
||||||
radius: { sm: 6, md: 12, lg: 16, xl: 24 },
|
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 = {
|
const DarkTheme: Theme = {
|
||||||
colors: {
|
colors: {
|
||||||
primary: "#60A5FA",
|
primary: "#E89B78",
|
||||||
primaryLight: "#1E3A5F",
|
primaryLight: "#2D1F18",
|
||||||
secondary: "#34D399",
|
primaryDark: "#D4764A",
|
||||||
background: "#111827",
|
secondary: "#E8ECF0",
|
||||||
surface: "#1F2937",
|
secondaryLight: "#2A3040",
|
||||||
card: "#1F2937",
|
background: "#0F0F1A",
|
||||||
border: "#374151",
|
surface: "#1A1A2E",
|
||||||
text: "#F9FAFB",
|
surfaceAlt: "#222240",
|
||||||
|
card: "#1A1A2E",
|
||||||
|
border: "#2A2A45",
|
||||||
|
borderLight: "#222240",
|
||||||
|
text: "#F0EDE8",
|
||||||
textSecondary: "#9CA3AF",
|
textSecondary: "#9CA3AF",
|
||||||
textTertiary: "#6B7280",
|
textTertiary: "#6B7280",
|
||||||
|
textInverse: "#FFFFFF",
|
||||||
error: "#F87171",
|
error: "#F87171",
|
||||||
errorLight: "#451111",
|
errorLight: "#451111",
|
||||||
|
success: "#6EC99B",
|
||||||
|
successLight: "#1A3A2E",
|
||||||
star: "#FBBF24",
|
star: "#FBBF24",
|
||||||
|
starLight: "#3A2E1A",
|
||||||
|
tabActive: "#E89B78",
|
||||||
|
tabInactive: "#6B7280",
|
||||||
},
|
},
|
||||||
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
|
spacing,
|
||||||
radius: { sm: 6, md: 12, lg: 16, xl: 24 },
|
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);
|
const ThemeContext = createContext<Theme>(LightTheme);
|
||||||
|
|
||||||
|
|||||||
@ -3,28 +3,41 @@ import { useTheme } from "../theme";
|
|||||||
|
|
||||||
interface BadgeProps {
|
interface BadgeProps {
|
||||||
label: string;
|
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 theme = useTheme();
|
||||||
|
|
||||||
const variants = {
|
const styles = {
|
||||||
default: { bg: theme.colors.surface, text: theme.colors.textSecondary },
|
default: { bg: theme.colors.surfaceAlt, text: theme.colors.textSecondary, border: theme.colors.borderLight },
|
||||||
primary: { bg: theme.colors.primaryLight, text: theme.colors.primary },
|
primary: { bg: theme.colors.primaryLight, text: theme.colors.primaryDark, border: theme.colors.primaryLight },
|
||||||
success: { bg: "#D1FAE5", text: "#059669" },
|
success: { bg: theme.colors.successLight, text: theme.colors.success, border: theme.colors.successLight },
|
||||||
error: { bg: theme.colors.errorLight, text: theme.colors.error },
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
accessible
|
accessible
|
||||||
accessibilityLabel={label}
|
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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,13 +1,14 @@
|
|||||||
import { Pressable, Text, ActivityIndicator, type ViewStyle } from "react-native";
|
import { Pressable, Text, ActivityIndicator, type ViewStyle } from "react-native";
|
||||||
import { useTheme } from "../theme";
|
import { useTheme } from "../theme";
|
||||||
|
|
||||||
type Variant = "primary" | "secondary" | "outline" | "destructive";
|
type Variant = "primary" | "secondary" | "outline" | "ghost" | "destructive";
|
||||||
|
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
@ -19,6 +20,7 @@ export function Button({
|
|||||||
loading = false,
|
loading = false,
|
||||||
size = "md",
|
size = "md",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
fullWidth = false,
|
||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
onPress,
|
onPress,
|
||||||
@ -27,22 +29,24 @@ export function Button({
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const sizeStyles: Record<string, ViewStyle> = {
|
const sizeStyles: Record<string, ViewStyle> = {
|
||||||
sm: { paddingVertical: theme.spacing.sm, paddingHorizontal: theme.spacing.md, borderRadius: theme.radius.sm },
|
sm: { paddingVertical: 8, paddingHorizontal: theme.spacing.md, borderRadius: theme.radius.sm },
|
||||||
md: { paddingVertical: 12, paddingHorizontal: theme.spacing.lg, borderRadius: theme.radius.md },
|
md: { paddingVertical: 14, paddingHorizontal: theme.spacing.lg, borderRadius: theme.radius.md },
|
||||||
lg: { paddingVertical: 16, paddingHorizontal: theme.spacing.xl, borderRadius: theme.radius.md },
|
lg: { paddingVertical: 18, paddingHorizontal: theme.spacing.xl, borderRadius: theme.radius.lg },
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantStyles: Record<Variant, ViewStyle> = {
|
const variantStyles: Record<Variant, ViewStyle> = {
|
||||||
primary: { backgroundColor: theme.colors.primary },
|
primary: { backgroundColor: theme.colors.primary, ...theme.shadows.button },
|
||||||
secondary: { backgroundColor: theme.colors.secondary },
|
secondary: { backgroundColor: theme.colors.surface, borderWidth: 1.5, borderColor: theme.colors.border },
|
||||||
outline: { backgroundColor: "transparent", borderWidth: 1, borderColor: theme.colors.primary },
|
outline: { backgroundColor: "transparent", borderWidth: 1.5, borderColor: theme.colors.primary },
|
||||||
|
ghost: { backgroundColor: "transparent" },
|
||||||
destructive: { backgroundColor: theme.colors.error },
|
destructive: { backgroundColor: theme.colors.error },
|
||||||
};
|
};
|
||||||
|
|
||||||
const textColors: Record<Variant, string> = {
|
const textColors: Record<Variant, string> = {
|
||||||
primary: "#FFFFFF",
|
primary: theme.colors.textInverse,
|
||||||
secondary: "#FFFFFF",
|
secondary: theme.colors.text,
|
||||||
outline: theme.colors.primary,
|
outline: theme.colors.primary,
|
||||||
|
ghost: theme.colors.primary,
|
||||||
destructive: "#FFFFFF",
|
destructive: "#FFFFFF",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,17 +61,30 @@ export function Button({
|
|||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[
|
style={[
|
||||||
{ alignItems: "center", justifyContent: "center" },
|
{
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
},
|
||||||
sizeStyles[size],
|
sizeStyles[size],
|
||||||
variantStyles[variant],
|
variantStyles[variant],
|
||||||
|
fullWidth && { width: "100%" },
|
||||||
disabled && { opacity: 0.5 },
|
disabled && { opacity: 0.5 },
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color={variant === "outline" ? theme.colors.primary : "#FFFFFF"} size="small" />
|
<ActivityIndicator color={textColors[variant]} size="small" />
|
||||||
) : typeof children === "string" ? (
|
) : 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}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,27 +1,52 @@
|
|||||||
import { View, type ViewStyle } from "react-native";
|
import { View, Pressable, type ViewStyle } from "react-native";
|
||||||
import { useTheme } from "../theme";
|
import { useTheme } from "../theme";
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
onPress?: () => void;
|
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 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 (
|
return (
|
||||||
<View
|
<View style={[variantStyles[variant], style]}>
|
||||||
style={[
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.card,
|
|
||||||
borderRadius: theme.radius.lg,
|
|
||||||
padding: theme.spacing.md,
|
|
||||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</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";
|
import { useTheme } from "../theme";
|
||||||
|
|
||||||
interface InputProps extends TextInputProps {
|
interface InputProps extends TextInputProps {
|
||||||
error?: string;
|
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();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<View style={[{ gap: theme.spacing.xs }, containerStyle]}>
|
||||||
accessible
|
{label && (
|
||||||
accessibilityLabel={props.placeholder || "Внесете текст"}
|
<Text style={{ color: theme.colors.textSecondary, ...theme.typography.captionBold }}>
|
||||||
accessibilityRole="none"
|
{label}
|
||||||
placeholderTextColor={theme.colors.textTertiary}
|
</Text>
|
||||||
style={[
|
)}
|
||||||
{
|
<TextInput
|
||||||
backgroundColor: theme.colors.surface,
|
accessible
|
||||||
borderColor: error ? theme.colors.error : theme.colors.border,
|
accessibilityLabel={label || props.placeholder || "Внесете текст"}
|
||||||
borderWidth: 1,
|
accessibilityRole="none"
|
||||||
borderRadius: theme.radius.md,
|
placeholderTextColor={theme.colors.textTertiary}
|
||||||
paddingVertical: 12,
|
style={[
|
||||||
paddingHorizontal: theme.spacing.md,
|
{
|
||||||
fontSize: 16,
|
backgroundColor: theme.colors.surface,
|
||||||
color: theme.colors.text,
|
borderColor: error ? theme.colors.error : theme.colors.border,
|
||||||
},
|
borderWidth: 1.5,
|
||||||
error && { borderColor: theme.colors.error },
|
borderRadius: theme.radius.md,
|
||||||
typeof style === "object" ? style : undefined,
|
paddingVertical: 14,
|
||||||
]}
|
paddingHorizontal: theme.spacing.md,
|
||||||
{...props}
|
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",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"convex": "^1.24.0",
|
"convex": "^1.24.0",
|
||||||
"expo": "~55.0.26",
|
"expo": "~55.0.26",
|
||||||
"expo-constants": "~55.0.16",
|
"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": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.83.6",
|
"version": "0.83.6",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.83.6.tgz",
|
||||||
@ -5888,6 +5901,15 @@
|
|||||||
"node": ">=0.12.0"
|
"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": {
|
"node_modules/is-wsl": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||||
@ -6352,9 +6374,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -6375,9 +6394,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -6398,9 +6414,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -6421,9 +6434,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -6626,6 +6636,18 @@
|
|||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"convex": "^1.24.0",
|
"convex": "^1.24.0",
|
||||||
"expo": "~55.0.26",
|
"expo": "~55.0.26",
|
||||||
"expo-constants": "~55.0.16",
|
"expo-constants": "~55.0.16",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user