ui cleanup
This commit is contained in:
parent
7162fb87e8
commit
be2bde41a2
0
.windsurf/workflows/login.md
Normal file
0
.windsurf/workflows/login.md
Normal file
20
app.json
20
app.json
|
|
@ -13,7 +13,8 @@
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.yaltopia.ticketapp"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|
@ -22,8 +23,23 @@
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"package": "com.yaltopia.ticketapp"
|
"package": "com.yaltopia.ticketapp",
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.READ_SMS",
|
||||||
|
"android.permission.RECEIVE_SMS",
|
||||||
|
"android.permission.CAMERA",
|
||||||
|
"android.permission.RECORD_AUDIO"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "Allow Yaltopia Tickets App to access your camera to scan invoices."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["@react-native-google-signin/google-signin"]
|
||||||
|
],
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png",
|
"favicon": "./assets/favicon.png",
|
||||||
"bundler": "metro"
|
"bundler": "metro"
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import { Tabs, router } from "expo-router";
|
import { Tabs, router } from "expo-router";
|
||||||
import { Home, ScanLine, FileText, Wallet, Newspaper, Scan } from "@/lib/icons";
|
import { Home, ScanLine, FileText, Wallet, Newspaper, Scan } from "@/lib/icons";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
import { Platform, View, Pressable } from "react-native";
|
import { Platform, View, Pressable } from "react-native";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
|
||||||
|
|
||||||
const NAV_BG = "#ffffff";
|
const ACTIVE_TINT = "rgba(228, 98, 18, 1)";
|
||||||
const ACTIVE_TINT = "#ea580c";
|
|
||||||
const INACTIVE_TINT = "#94a3b8";
|
const INACTIVE_TINT = "#94a3b8";
|
||||||
|
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
|
const NAV_BG =
|
||||||
|
colorScheme === "dark" ? "rgba(31,31,31, 1)" : "rgba(255,255,255, 1)";
|
||||||
|
const BORDER_COLOR = isDark ? "#1e293b" : "#ffffff";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
|
|
@ -15,6 +21,9 @@ export default function TabsLayout() {
|
||||||
tabBarShowLabel: true,
|
tabBarShowLabel: true,
|
||||||
tabBarActiveTintColor: ACTIVE_TINT,
|
tabBarActiveTintColor: ACTIVE_TINT,
|
||||||
tabBarInactiveTintColor: INACTIVE_TINT,
|
tabBarInactiveTintColor: INACTIVE_TINT,
|
||||||
|
tabBarButton: ({ ref, ...navProps }) => (
|
||||||
|
<Pressable {...navProps} android_ripple={null} />
|
||||||
|
),
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
|
|
@ -25,7 +34,11 @@ export default function TabsLayout() {
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: NAV_BG,
|
backgroundColor: NAV_BG,
|
||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
elevation: 10,
|
elevation: isDark ? 0 : 6,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: -10 },
|
||||||
|
shadowOpacity: isDark ? 0 : 0.1,
|
||||||
|
shadowRadius: 20,
|
||||||
height: Platform.OS === "ios" ? 75 : 75,
|
height: Platform.OS === "ios" ? 75 : 75,
|
||||||
paddingBottom: Platform.OS === "ios" ? 30 : 10,
|
paddingBottom: Platform.OS === "ios" ? 30 : 10,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
|
|
@ -35,10 +48,6 @@ export default function TabsLayout() {
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
borderRadius: 32,
|
borderRadius: 32,
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 10 },
|
|
||||||
shadowOpacity: 0.12,
|
|
||||||
shadowRadius: 20,
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -74,11 +83,14 @@ export default function TabsLayout() {
|
||||||
color: INACTIVE_TINT,
|
color: INACTIVE_TINT,
|
||||||
},
|
},
|
||||||
tabBarIcon: ({ focused }) => (
|
tabBarIcon: ({ focused }) => (
|
||||||
<ShadowWrapper level="lg" className="-mt-12">
|
<View className="-mt-12">
|
||||||
<View className="h-16 w-16 rounded-full bg-primary items-center justify-center border-4 border-white">
|
<View
|
||||||
|
className="h-16 w-16 rounded-full bg-primary items-center justify-center border-4"
|
||||||
|
style={{ borderColor: BORDER_COLOR }}
|
||||||
|
>
|
||||||
<ScanLine color="white" size={28} strokeWidth={3} />
|
<ScanLine color="white" size={28} strokeWidth={3} />
|
||||||
</View>
|
</View>
|
||||||
</ShadowWrapper>
|
</View>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
|
useColorScheme,
|
||||||
|
} from "react-native";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Send,
|
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
FileText,
|
FileText,
|
||||||
|
ScanLine,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
|
@ -74,22 +81,24 @@ export default function HomeScreen() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<StandardHeader />
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<StandardHeader />
|
||||||
{/* Balance Card Section */}
|
{/* Balance Card Section */}
|
||||||
|
<View className="px-[16px] pt-6">
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<ShadowWrapper level="lg">
|
|
||||||
<Card className="overflow-hidden rounded-[10px] border-0 bg-primary">
|
<Card className="overflow-hidden rounded-[10px] border-0 bg-primary">
|
||||||
<View className="p-4 relative">
|
<View className="p-4 relative">
|
||||||
<View
|
<View
|
||||||
|
|
@ -137,31 +146,55 @@ export default function HomeScreen() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
</ShadowWrapper>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Circular Quick Actions Section */}
|
{/* Circular Quick Actions Section */}
|
||||||
<View className="mb-4 flex-row justify-around items-center px-2">
|
<View className="mb-4 flex-row justify-around items-center px-2">
|
||||||
<QuickAction
|
<QuickAction
|
||||||
icon={<Briefcase color="#000" size={20} strokeWidth={1.5} />}
|
icon={
|
||||||
|
<Briefcase
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Company"
|
label="Company"
|
||||||
onPress={() => nav.go("company")}
|
onPress={() => nav.go("company")}
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
<QuickAction
|
||||||
icon={<Send color="#000" size={20} strokeWidth={1.5} />}
|
icon={
|
||||||
label="Send"
|
<ScanLine
|
||||||
onPress={() => nav.go("(tabs)/proforma")}
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
}
|
||||||
icon={<HistoryIcon color="#000" size={20} strokeWidth={1.5} />}
|
label="Scan SMS"
|
||||||
label="History"
|
onPress={() => nav.go("sms-scan")}
|
||||||
onPress={() => nav.go("history")}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<QuickAction
|
<QuickAction
|
||||||
icon={<Plus color="#000" size={20} strokeWidth={1.5} />}
|
icon={
|
||||||
|
<Plus
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Create Proforma"
|
label="Create Proforma"
|
||||||
onPress={() => nav.go("proforma/create")}
|
onPress={() => nav.go("proforma/create")}
|
||||||
/>
|
/>
|
||||||
|
<QuickAction
|
||||||
|
icon={
|
||||||
|
<HistoryIcon
|
||||||
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="History"
|
||||||
|
onPress={() => nav.go("history")}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Recent Activity Header */}
|
{/* Recent Activity Header */}
|
||||||
|
|
@ -253,7 +286,7 @@ export default function HomeScreen() {
|
||||||
${Number(inv.amount).toLocaleString()}
|
${Number(inv.amount).toLocaleString()}
|
||||||
</Text>
|
</Text>
|
||||||
<View
|
<View
|
||||||
className={`mt-1 rounded-[5px] px-3 py-1 border border-border/50 ${
|
className={`mt-1 rounded-[5px] px-3 py-1 border border-border ${
|
||||||
inv.status === "PAID"
|
inv.status === "PAID"
|
||||||
? "bg-emerald-500/30 text-emerald-600"
|
? "bg-emerald-500/30 text-emerald-600"
|
||||||
: inv.status === "PENDING"
|
: inv.status === "PENDING"
|
||||||
|
|
@ -274,11 +307,19 @@ export default function HomeScreen() {
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<View className="py-20 items-center">
|
<View className="py-10">
|
||||||
<Text variant="muted">No transactions found</Text>
|
<EmptyState
|
||||||
|
title="No transactions yet"
|
||||||
|
description="Your recent activity will show up here once you create and send invoices."
|
||||||
|
hint="Create a proforma invoice to get started."
|
||||||
|
actionLabel="Create Proforma"
|
||||||
|
onActionPress={() => nav.go("proforma/create")}
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
|
|
@ -294,16 +335,18 @@ function QuickAction({
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View className="items-center">
|
<View className="pt-2 items-center w-[75px]">
|
||||||
<ShadowWrapper>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className="h-12 w-12 rounded-full bg-background items-center justify-center mb-2"
|
className="h-12 w-12 rounded-full bg-card border border-border/20 items-center justify-center flex-shrink-0"
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ShadowWrapper>
|
<Text
|
||||||
<Text className="text-foreground text-[12px] font-semibold tracking-tight opacity-90">
|
variant="p"
|
||||||
|
className="flex-1 text-foreground text-[12px] font-bold tracking-tight text-center leading-4"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,11 @@ import { AppRoutes } from "@/lib/routes";
|
||||||
import { Newspaper, ChevronRight, Clock } from "@/lib/icons";
|
import { Newspaper, ChevronRight, Clock } from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
|
||||||
import { api, newsApi } from "@/lib/api";
|
import { api, newsApi } from "@/lib/api";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
|
||||||
const { width } = Dimensions.get("window");
|
const { width } = Dimensions.get("window");
|
||||||
const LATEST_CARD_WIDTH = width * 0.8;
|
const LATEST_CARD_WIDTH = width * 0.8;
|
||||||
|
|
@ -33,6 +36,7 @@ interface NewsItem {
|
||||||
|
|
||||||
export default function NewsScreen() {
|
export default function NewsScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const permissions = useAuthStore((s: { permissions: string[] }) => s.permissions);
|
||||||
|
|
||||||
// Safe accessor to handle initialization race conditions
|
// Safe accessor to handle initialization race conditions
|
||||||
const getNewsApi = () => {
|
const getNewsApi = () => {
|
||||||
|
|
@ -53,6 +57,8 @@ export default function NewsScreen() {
|
||||||
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// Check permissions (none for viewing news)
|
||||||
|
|
||||||
const fetchLatest = async () => {
|
const fetchLatest = async () => {
|
||||||
try {
|
try {
|
||||||
setLoadingLatest(true);
|
setLoadingLatest(true);
|
||||||
|
|
@ -217,8 +223,6 @@ export default function NewsScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<StandardHeader />
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{ paddingBottom: 120 }}
|
contentContainerStyle={{ paddingBottom: 120 }}
|
||||||
|
|
@ -230,6 +234,7 @@ export default function NewsScreen() {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<StandardHeader />
|
||||||
{/* Latest News Section */}
|
{/* Latest News Section */}
|
||||||
<View className="px-5 mt-4">
|
<View className="px-5 mt-4">
|
||||||
<Text variant="h4" className="text-foreground tracking-tight mb-4">
|
<Text variant="h4" className="text-foreground tracking-tight mb-4">
|
||||||
|
|
@ -251,10 +256,13 @@ export default function NewsScreen() {
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : (
|
) : (
|
||||||
<View className="bg-card/50 rounded-[12px] p-8 items-center border border-border/50">
|
<View className="py-4">
|
||||||
<Text variant="muted" className="text-xs font-medium">
|
<EmptyState
|
||||||
No latest items
|
title="No latest updates"
|
||||||
</Text>
|
description="Announcements and important updates will appear here once published."
|
||||||
|
hint="Pull to refresh to check again."
|
||||||
|
previewLines={2}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -289,19 +297,13 @@ export default function NewsScreen() {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className="py-20 items-center">
|
<View className="py-6">
|
||||||
<Newspaper
|
<EmptyState
|
||||||
color="#94a3b8"
|
title="No news yet"
|
||||||
size={48}
|
description="Company news, maintenance updates, and announcements will show up here."
|
||||||
strokeWidth={1}
|
hint="Pull to refresh to fetch the latest posts."
|
||||||
className="mb-4 opacity-20"
|
previewLines={4}
|
||||||
/>
|
/>
|
||||||
<Text
|
|
||||||
variant="muted"
|
|
||||||
className="font-bold uppercase tracking-widest text-[10px]"
|
|
||||||
>
|
|
||||||
No news items available
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,14 @@ import {
|
||||||
Wallet,
|
Wallet,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Plus,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
const PRIMARY = "#ea580c";
|
const PRIMARY = "#ea580c";
|
||||||
|
|
||||||
|
|
@ -52,6 +55,7 @@ interface Payment {
|
||||||
|
|
||||||
export default function PaymentsScreen() {
|
export default function PaymentsScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const permissions = useAuthStore((s) => s.permissions);
|
||||||
const [payments, setPayments] = useState<Payment[]>([]);
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
@ -59,6 +63,9 @@ export default function PaymentsScreen() {
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]);
|
||||||
|
|
||||||
const fetchPayments = useCallback(
|
const fetchPayments = useCallback(
|
||||||
async (pageNum: number, isRefresh = false) => {
|
async (pageNum: number, isRefresh = false) => {
|
||||||
const { isAuthenticated } = useAuthStore.getState();
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
|
|
@ -199,10 +206,9 @@ export default function PaymentsScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<StandardHeader />
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
|
contentContainerStyle={{ paddingBottom: 150 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={({ nativeEvent }) => {
|
onScroll={({ nativeEvent }) => {
|
||||||
const isCloseToBottom =
|
const isCloseToBottom =
|
||||||
|
|
@ -213,16 +219,19 @@ export default function PaymentsScreen() {
|
||||||
}}
|
}}
|
||||||
scrollEventThrottle={400}
|
scrollEventThrottle={400}
|
||||||
>
|
>
|
||||||
|
<StandardHeader />
|
||||||
|
<View className="px-[16px] pt-6">
|
||||||
<Button
|
<Button
|
||||||
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
|
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
|
||||||
onPress={() => nav.go("sms-scan")}
|
onPress={() => nav.go("payment-requests/create")}
|
||||||
>
|
>
|
||||||
<ScanLine color="#ffffff" size={18} strokeWidth={2.5} />
|
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
|
||||||
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
|
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
|
||||||
Scan SMS
|
Create Payment Request
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
{/* Flagged Section */}
|
{/* Flagged Section */}
|
||||||
{categorized.flagged.length > 0 && (
|
{categorized.flagged.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -247,9 +256,14 @@ export default function PaymentsScreen() {
|
||||||
{categorized.pending.length > 0 ? (
|
{categorized.pending.length > 0 ? (
|
||||||
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
|
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
|
||||||
) : (
|
) : (
|
||||||
<Text variant="muted" className="text-center py-4">
|
<View className="py-1">
|
||||||
No pending matches.
|
<EmptyState
|
||||||
</Text>
|
title="No pending payments"
|
||||||
|
description="Payments that haven't been matched to invoices yet will appear here."
|
||||||
|
hint="Upload receipts or scan SMS to add payments."
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -265,9 +279,14 @@ export default function PaymentsScreen() {
|
||||||
renderPaymentItem(p, "reconciled"),
|
renderPaymentItem(p, "reconciled"),
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Text variant="muted" className="text-center py-4">
|
<View className="py-4">
|
||||||
No reconciled payments.
|
<EmptyState
|
||||||
</Text>
|
title="No reconciled payments"
|
||||||
|
description="Payments matched to invoices will show up here once reconciled."
|
||||||
|
hint="Match pending payments to invoices for reconciliation."
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -276,6 +295,7 @@ export default function PaymentsScreen() {
|
||||||
<ActivityIndicator color={PRIMARY} />
|
<ActivityIndicator color={PRIMARY} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,26 +10,68 @@ import { Text } from "@/components/ui/text";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Plus, Send, FileText, Clock } from "@/lib/icons";
|
import { Plus, Send, FileText, Clock, ChevronRight } from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
interface ProformaItem {
|
interface ProformaItem {
|
||||||
id: string;
|
id: string;
|
||||||
proformaNumber: string;
|
proformaNumber: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
customerPhone: string;
|
||||||
amount: any;
|
amount: any;
|
||||||
currency: string;
|
currency: string;
|
||||||
issueDate: string;
|
issueDate: string;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
notes: string;
|
||||||
|
taxAmount: any;
|
||||||
|
discountAmount: any;
|
||||||
|
pdfPath: string;
|
||||||
|
userId: string;
|
||||||
|
items: any[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dummyData: ProformaItem = {
|
||||||
|
id: "dummy-1",
|
||||||
|
proformaNumber: "PF-001",
|
||||||
|
customerName: "John Doe",
|
||||||
|
customerEmail: "john@example.com",
|
||||||
|
customerPhone: "+1234567890",
|
||||||
|
amount: { value: 1000, currency: "USD" },
|
||||||
|
currency: "USD",
|
||||||
|
issueDate: "2026-03-10T11:51:36.134Z",
|
||||||
|
dueDate: "2026-03-10T11:51:36.134Z",
|
||||||
|
description: "Dummy proforma",
|
||||||
|
notes: "Test notes",
|
||||||
|
taxAmount: { value: 100, currency: "USD" },
|
||||||
|
discountAmount: { value: 50, currency: "USD" },
|
||||||
|
pdfPath: "dummy.pdf",
|
||||||
|
userId: "user-1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
description: "Test item",
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: { value: 1000, currency: "USD" },
|
||||||
|
total: { value: 1000, currency: "USD" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: "2026-03-10T11:51:36.134Z",
|
||||||
|
updatedAt: "2026-03-10T11:51:36.134Z"
|
||||||
|
};
|
||||||
|
|
||||||
export default function ProformaScreen() {
|
export default function ProformaScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const permissions = useAuthStore((s) => s.permissions);
|
||||||
const [proformas, setProformas] = useState<ProformaItem[]>([]);
|
const [proformas, setProformas] = useState<ProformaItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
@ -37,6 +79,9 @@ export default function ProformaScreen() {
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const canCreateProformas = hasPermission(permissions, PERMISSION_MAP["proforma:create"]);
|
||||||
|
|
||||||
const fetchProformas = useCallback(
|
const fetchProformas = useCallback(
|
||||||
async (pageNum: number, isRefresh = false) => {
|
async (pageNum: number, isRefresh = false) => {
|
||||||
const { isAuthenticated } = useAuthStore.getState();
|
const { isAuthenticated } = useAuthStore.getState();
|
||||||
|
|
@ -51,7 +96,10 @@ export default function ProformaScreen() {
|
||||||
query: { page: pageNum, limit: 10 },
|
query: { page: pageNum, limit: 10 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const newData = response.data;
|
let newProformas = response.data;
|
||||||
|
|
||||||
|
|
||||||
|
const newData = newProformas;
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
setProformas(newData);
|
setProformas(newData);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -59,11 +107,11 @@ export default function ProformaScreen() {
|
||||||
pageNum === 1 ? newData : [...prev, ...newData],
|
pageNum === 1 ? newData : [...prev, ...newData],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasMore(response.meta.hasNextPage);
|
setHasMore(response.meta.hasNextPage);
|
||||||
setPage(pageNum);
|
setPage(pageNum);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[Proforma] Fetch error:", err);
|
console.error("[Proforma] Fetch error:", err);
|
||||||
|
setHasMore(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
|
|
@ -91,101 +139,90 @@ export default function ProformaScreen() {
|
||||||
const renderProformaItem: ListRenderItem<ProformaItem> = ({ item }) => {
|
const renderProformaItem: ListRenderItem<ProformaItem> = ({ item }) => {
|
||||||
const amountVal =
|
const amountVal =
|
||||||
typeof item.amount === "object" ? item.amount.value : item.amount;
|
typeof item.amount === "object" ? item.amount.value : item.amount;
|
||||||
const dateStr = new Date(item.issueDate).toLocaleDateString();
|
const issuedStr = item.issueDate
|
||||||
|
? new Date(item.issueDate).toLocaleDateString()
|
||||||
|
: "";
|
||||||
|
const dueStr = item.dueDate ? new Date(item.dueDate).toLocaleDateString() : "";
|
||||||
|
const itemsCount = Array.isArray(item.items) ? item.items.length : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View className="px-[16px]">
|
||||||
<Pressable
|
<Pressable
|
||||||
key={item.id}
|
|
||||||
onPress={() => nav.go("proforma/[id]", { id: item.id })}
|
onPress={() => nav.go("proforma/[id]", { id: item.id })}
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
>
|
>
|
||||||
<Card className="rounded-[10px] bg-card overflow-hidden">
|
<Card className="rounded-[12px] bg-card overflow-hidden border border-border/40">
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<View className="flex-row justify-between items-start mb-3">
|
<View className="flex-row items-start">
|
||||||
<View className="bg-primary/10 p-2.5 rounded-[12px] border border-primary/5">
|
<View className="bg-primary/10 p-2.5 rounded-[12px] border border-primary/10 mr-3">
|
||||||
<FileText color="#ea580c" size={20} strokeWidth={2.5} />
|
<FileText color="#ea580c" size={18} strokeWidth={2.5} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<View className="flex-1 pr-2">
|
||||||
|
<Text className="text-foreground font-semibold" numberOfLines={1}>
|
||||||
|
{item.proformaNumber || "Proforma"}
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5" numberOfLines={1}>
|
||||||
|
{item.customerName || "Customer"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="items-end">
|
<View className="items-end">
|
||||||
<Text variant="p" className="text-foreground font-bold text-lg">
|
<Text className="text-foreground font-bold text-base">
|
||||||
{item.currency || "$"}
|
{item.currency || "$"}
|
||||||
{amountVal?.toLocaleString()}
|
{amountVal?.toLocaleString?.() ?? amountVal ?? "0"}
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
variant="muted"
|
|
||||||
className="text-[10px] font-bold uppercase tracking-widest mt-0.5"
|
|
||||||
>
|
|
||||||
{item.proformaNumber}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text variant="p" className="text-foreground font-bold mb-1">
|
<View className="mt-2 flex-row items-center justify-between">
|
||||||
{item.customerName}
|
<Text variant="muted" className="text-[10px] font-medium">
|
||||||
</Text>
|
Issued: {issuedStr} | Due: {dueStr} | {itemsCount} item{itemsCount !== 1 ? "s" : ""}
|
||||||
{item.description && (
|
|
||||||
<Text
|
|
||||||
variant="muted"
|
|
||||||
className="text-xs line-clamp-1 mb-4 opacity-70"
|
|
||||||
>
|
|
||||||
{item.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View className="h-[1px] bg-border/50 mb-4" />
|
|
||||||
|
|
||||||
<View className="flex-row justify-between items-center">
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<View className="p-1 bg-secondary/80 rounded-md">
|
|
||||||
<Clock color="#64748b" size={12} strokeWidth={2.5} />
|
|
||||||
</View>
|
|
||||||
<Text variant="muted" className="text-[11px] font-medium">
|
|
||||||
Issued: {dateStr}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
|
||||||
className="bg-primary/10 px-3.5 py-1.5 rounded-full border border-primary/20 flex-row items-center gap-1.5"
|
</View>
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// Handle share
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Send color="#ea580c" size={12} strokeWidth={2.5} />
|
|
||||||
<Text className="text-primary text-[11px] font-bold uppercase tracking-tight">
|
|
||||||
Share
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<StandardHeader />
|
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={proformas}
|
data={proformas}
|
||||||
renderItem={renderProformaItem}
|
renderItem={renderProformaItem}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
|
contentContainerStyle={{ paddingBottom: 150 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onEndReached={loadMore}
|
onEndReached={loadMore}
|
||||||
onEndReachedThreshold={0.5}
|
onEndReachedThreshold={0.5}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
|
<>
|
||||||
|
<StandardHeader />
|
||||||
|
<View className="px-[16px] pt-6">
|
||||||
|
{/* {canCreateProformas && ( */}
|
||||||
<Button
|
<Button
|
||||||
className="mb-6 h-12 rounded-[14px] bg-primary shadow-lg shadow-primary/30"
|
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
|
||||||
onPress={() => nav.go("proforma/create")}
|
onPress={() => nav.go("proforma/create")}
|
||||||
>
|
>
|
||||||
<Plus color="white" size={20} strokeWidth={3} />
|
<Plus color="white" size={20} strokeWidth={3} />
|
||||||
<Text className="text-white text-sm font-bold uppercase tracking-widest ml-1">
|
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
|
||||||
Create New Proforma
|
Create New Proforma
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* )} */}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
loadingMore ? (
|
loadingMore ? (
|
||||||
|
|
@ -194,8 +231,13 @@ export default function ProformaScreen() {
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
!loading ? (
|
!loading ? (
|
||||||
<View className="py-20 items-center">
|
<View className="px-[16px] py-6">
|
||||||
<Text variant="muted">No proformas found</Text>
|
<EmptyState
|
||||||
|
title="No proformas yet"
|
||||||
|
description="Create your first proforma to get started with invoicing."
|
||||||
|
hint="Tap the button above to create a new proforma."
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="py-20">
|
<View className="py-20">
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export default function ScanScreen() {
|
||||||
|
|
||||||
if (!permission.granted) {
|
if (!permission.granted) {
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background items-center justify-center p-10 px-16">
|
<ScreenWrapper className="bg-background items-center justify-center">
|
||||||
<View className="bg-primary/10 p-6 rounded-[24px] mb-6">
|
<View className="bg-primary/10 p-6 rounded-[24px] mb-6">
|
||||||
<CameraIcon className="text-primary" size={48} strokeWidth={1.5} />
|
<CameraIcon className="text-primary" size={48} strokeWidth={1.5} />
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -175,9 +175,9 @@ export default function ScanScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Scan Frame */}
|
{/* Scan Frame */}
|
||||||
<View className="items-center">
|
<View className="items-center mt-10">
|
||||||
<View className="w-72 h-72 border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
|
<View className="w-[300px] h-[500px] border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
|
||||||
<View className="w-64 h-64 border border-white/10 rounded-2xl" />
|
<View className="w-[280px] h-[480px] border border-white/10 rounded-2xl" />
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
|
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
|
||||||
Align Invoice Within Frame
|
Align Invoice Within Frame
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,18 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { Toast } from "@/components/Toast";
|
import { Toast } from "@/components/Toast";
|
||||||
import "@/global.css";
|
import "@/global.css";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { View, ActivityIndicator } from "react-native";
|
import { View, ActivityIndicator, InteractionManager } from "react-native";
|
||||||
import { useRestoreTheme } from "@/lib/theme";
|
import { useRestoreTheme, NAV_THEME } from "@/lib/theme";
|
||||||
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
|
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { NavigationContainer, NavigationIndependentTree, ThemeProvider } from "@react-navigation/native";
|
||||||
import { routes } from "@/lib/routes";
|
import { routes } from "@/lib/routes";
|
||||||
import { authGuard, guestGuard } from "@/lib/auth-guards";
|
import { authGuard, guestGuard } from "@/lib/auth-guards";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { useFonts } from 'expo-font';
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
import { useSegments, router as expoRouter } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
|
|
||||||
function BackupGuard() {
|
function BackupGuard() {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
|
@ -28,13 +31,9 @@ function BackupGuard() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
|
|
||||||
const rootSegment = segments[0];
|
// Intentionally disabled: redirecting here can happen before the root layout
|
||||||
const isPublic = rootSegment === "login" || rootSegment === "register";
|
// navigator is ready and cause "Attempted to navigate before mounting".
|
||||||
|
// Sirou guards handle redirects.
|
||||||
if (!isAuthed && !isPublic && segments.length > 0) {
|
|
||||||
console.log("[BackupGuard] Safety redirect to /login");
|
|
||||||
expoRouter.replace("/login");
|
|
||||||
}
|
|
||||||
}, [segments, isAuthed, isMounted]);
|
}, [segments, isAuthed, isMounted]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -64,8 +63,10 @@ function SirouBridge() {
|
||||||
const result = await (sirou as any).checkGuards(routeName);
|
const result = await (sirou as any).checkGuards(routeName);
|
||||||
if (!result.allowed && result.redirect) {
|
if (!result.allowed && result.redirect) {
|
||||||
console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`);
|
console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`);
|
||||||
// Use expoRouter for filesystem navigation
|
// Use Sirou navigation safely
|
||||||
expoRouter.replace(`/${result.redirect}`);
|
InteractionManager.runAfterInteractions(() => {
|
||||||
|
sirou.go(result.redirect);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|
@ -82,9 +83,21 @@ function SirouBridge() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
useRestoreTheme();
|
useRestoreTheme();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
const [hasHydrated, setHasHydrated] = useState(false);
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
const [fontsLoaded] = useFonts({
|
||||||
|
'DMSans-Regular': require('../assets/fonts/DMSans-Regular.ttf'),
|
||||||
|
'DMSans-Bold': require('../assets/fonts/DMSans-Bold.ttf'),
|
||||||
|
'DMSans-Medium': require('../assets/fonts/DMSans-Medium.ttf'),
|
||||||
|
'DMSans-SemiBold': require('../assets/fonts/DMSans-SemiBold.ttf'),
|
||||||
|
'DMSans-Light': require('../assets/fonts/DMSans-Light.ttf'),
|
||||||
|
'DMSans-ExtraLight': require('../assets/fonts/DMSans-ExtraLight.ttf'),
|
||||||
|
'DMSans-Thin': require('../assets/fonts/DMSans-Thin.ttf'),
|
||||||
|
'DMSans-Black': require('../assets/fonts/DMSans-Black.ttf'),
|
||||||
|
'DMSans-ExtraBold': require('../assets/fonts/DMSans-ExtraBold.ttf'),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
|
|
@ -103,14 +116,14 @@ export default function RootLayout() {
|
||||||
initializeAuth();
|
initializeAuth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!isMounted || !hasHydrated) {
|
if (!isMounted || !hasHydrated || !fontsLoaded) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#fff",
|
backgroundColor: "rgba(255, 255, 255, 1)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ActivityIndicator size="large" color="#ea580c" />
|
<ActivityIndicator size="large" color="#ea580c" />
|
||||||
|
|
@ -119,22 +132,26 @@ export default function RootLayout() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
|
||||||
<SirouBridge />
|
|
||||||
<BackupGuard />
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
|
<NavigationIndependentTree>
|
||||||
|
<NavigationContainer>
|
||||||
|
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
|
||||||
|
<ThemeProvider
|
||||||
|
value={colorScheme === "dark" ? NAV_THEME.dark : NAV_THEME.light}
|
||||||
|
>
|
||||||
<View className="flex-1 bg-background">
|
<View className="flex-1 bg-background">
|
||||||
<StatusBar style="light" />
|
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerStyle: { backgroundColor: "#2d2d2d" },
|
headerShown: false,
|
||||||
headerTintColor: "#ffffff",
|
|
||||||
headerTitleStyle: { fontWeight: "600" },
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="sms-scan" options={{ headerShown: false }} />
|
<Stack.Screen
|
||||||
|
name="sms-scan"
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="proforma/[id]"
|
name="proforma/[id]"
|
||||||
options={{ title: "Proforma request" }}
|
options={{ title: "Proforma request" }}
|
||||||
|
|
@ -151,6 +168,15 @@ export default function RootLayout() {
|
||||||
name="notifications/settings"
|
name="notifications/settings"
|
||||||
options={{ title: "Notification settings" }}
|
options={{ title: "Notification settings" }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen name="help" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="terms" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="privacy" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="history" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="company" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="company-details"
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{ title: "Sign in", headerShown: false }}
|
options={{ title: "Sign in", headerShown: false }}
|
||||||
|
|
@ -178,11 +204,16 @@ export default function RootLayout() {
|
||||||
options={{ headerShown: false }}
|
options={{ headerShown: false }}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<SirouBridge />
|
||||||
|
<BackupGuard />
|
||||||
<PortalHost />
|
<PortalHost />
|
||||||
<Toast />
|
<Toast />
|
||||||
</View>
|
</View>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SirouRouterProvider>
|
||||||
|
</NavigationContainer>
|
||||||
|
</NavigationIndependentTree>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</SirouRouterProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
220
app/company-details.tsx
Normal file
220
app/company-details.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { View, ActivityIndicator, ScrollView, Image } from "react-native";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function CompanyDetailsScreen() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [company, setCompany] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await api.company.get();
|
||||||
|
setCompany(res?.data ?? res);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader title="Company details" showBack />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
{company?.logoPath && (
|
||||||
|
<View className="items-center mb-6">
|
||||||
|
<View className="h-20 w-20 rounded-full overflow-hidden bg-muted">
|
||||||
|
<Image
|
||||||
|
source={{ uri: company.logoPath }}
|
||||||
|
className="h-full w-full"
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<View className="mb-3">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Company Name
|
||||||
|
</Text>
|
||||||
|
<Text variant="h4" className="text-foreground mt-1">
|
||||||
|
{company?.name ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{company?.tin && (
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
TIN
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company.tin}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold mb-3">
|
||||||
|
Contact Information
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-3">
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Phone
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.phone ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Email
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.email ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{company?.website && (
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Website
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company.website}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold mb-3">
|
||||||
|
Address
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-3">
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Street Address
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.address ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
City
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.city ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
State
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.state ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Zip Code
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.zipCode ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Country
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.country ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* System Info */}
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text variant="muted" className="text-xs font-semibold mb-3">
|
||||||
|
System Information
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-3">
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
User ID
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1 font-mono text-sm">
|
||||||
|
{company?.userId ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Created
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.createdAt ? new Date(company.createdAt).toLocaleString() : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text variant="muted" className="text-xs font-semibold">
|
||||||
|
Last Updated
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground mt-1">
|
||||||
|
{company?.updatedAt ? new Date(company.updatedAt).toLocaleString() : "—"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,9 @@ import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
import {
|
import {
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Search,
|
Search,
|
||||||
|
|
@ -24,6 +26,7 @@ import {
|
||||||
Phone,
|
Phone,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
Info,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
|
|
||||||
export default function CompanyScreen() {
|
export default function CompanyScreen() {
|
||||||
|
|
@ -67,7 +70,7 @@ export default function CompanyScreen() {
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<Stack.Screen options={{ headerShown: false }} />
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
<StandardHeader title="Company" showBack />
|
<StandardHeader title="Company" showBack rightAction="companyInfo" />
|
||||||
|
|
||||||
<View className="flex-1 px-5 pt-4">
|
<View className="flex-1 px-5 pt-4">
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
|
|
@ -77,7 +80,7 @@ export default function CompanyScreen() {
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground"
|
className="flex-1 ml-3 text-foreground"
|
||||||
placeholder="Search workers..."
|
placeholder="Search workers..."
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChangeText={setSearchQuery}
|
onChangeText={setSearchQuery}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ export default function EditProfileScreen() {
|
||||||
>
|
>
|
||||||
First Name
|
First Name
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-14">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-14">
|
||||||
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground text-base h-12"
|
className="flex-1 ml-3 text-foreground text-base h-12"
|
||||||
|
|
@ -113,7 +113,7 @@ export default function EditProfileScreen() {
|
||||||
>
|
>
|
||||||
Last Name
|
Last Name
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-14">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-14">
|
||||||
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground text-base h-12"
|
className="flex-1 ml-3 text-foreground text-base h-12"
|
||||||
|
|
|
||||||
59
app/help.tsx
Normal file
59
app/help.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
const FAQ = [
|
||||||
|
{
|
||||||
|
q: "How do I change the theme?",
|
||||||
|
a: "Go to Profile > Appearance and choose Light, Dark, or System.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Where can I find my invoices and proformas?",
|
||||||
|
a: "Use the tabs on the bottom navigation to browse Invoices/Payments and Proformas.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Why am I seeing an API error?",
|
||||||
|
a: "If your backend is rate-limiting or the database schema is missing columns, the app may show errors. Contact your admin or check the server logs.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HelpScreen() {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader title="Help & Support" showBack />
|
||||||
|
|
||||||
|
<View className="px-5 pt-4 pb-10 gap-3">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text variant="h4" className="text-foreground">
|
||||||
|
FAQ
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="mt-1">
|
||||||
|
Quick answers to common questions.
|
||||||
|
</Text>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{FAQ.map((item) => (
|
||||||
|
<Card key={item.q} className="border border-border">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text className="text-foreground font-semibold">{item.q}</Text>
|
||||||
|
<Text className="text-muted-foreground mt-2">{item.a}</Text>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<Text className="text-foreground font-semibold">Need more help?</Text>
|
||||||
|
<Text className="text-muted-foreground mt-2">
|
||||||
|
Placeholder — add contact info (email/phone/WhatsApp) or a support chat link here.
|
||||||
|
</Text>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
|
@ -174,11 +175,15 @@ export default function HistoryScreen() {
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<View className="py-20 items-center opacity-40">
|
<View className="py-6">
|
||||||
<FileText size={48} color="#94a3b8" strokeWidth={1} />
|
<EmptyState
|
||||||
<Text variant="muted" className="mt-4 font-bold">
|
title="No activity yet"
|
||||||
No activity found
|
description="Payments and invoices you create will show up here so you can track everything in one place."
|
||||||
</Text>
|
hint="Create a proforma invoice to generate your first activity."
|
||||||
|
actionLabel="Create Proforma"
|
||||||
|
onActionPress={() => nav.go("proforma/create")}
|
||||||
|
previewLines={4}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -212,25 +212,65 @@ export default function InvoiceDetailScreen() {
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Notes Section (New) */}
|
||||||
|
{invoice.notes && (
|
||||||
|
<Card className="mb-4 bg-card rounded-[6px]">
|
||||||
|
<View className="p-4">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-bold opacity-60 uppercase text-[10px] tracking-widest mb-2"
|
||||||
|
>
|
||||||
|
Additional Notes
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="p"
|
||||||
|
className="text-foreground font-medium text-xs leading-5"
|
||||||
|
>
|
||||||
|
{invoice.notes}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline Section (New) */}
|
||||||
|
<View className="mt-2 mb-6 px-4 py-3 bg-secondary/20 rounded-[8px] border border-border/30">
|
||||||
|
<View className="flex-row justify-between mb-1.5">
|
||||||
|
<Text className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">
|
||||||
|
Created
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[10px] text-foreground font-bold">
|
||||||
|
{new Date(invoice.createdAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter">
|
||||||
|
Last Updated
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[10px] text-foreground font-bold">
|
||||||
|
{new Date(invoice.updatedAt).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<Button
|
<Button
|
||||||
className=" flex-1 mb-4 h-11 rounded-[6px] bg-primary shadow-lg shadow-primary/30"
|
className=" flex-1 mb-4 h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
|
||||||
onPress={() => {}}
|
onPress={() => {}}
|
||||||
>
|
>
|
||||||
<Share2 color="#ffffff" size={14} strokeWidth={2.5} />
|
<Share2 color="#ffffff" size={16} strokeWidth={2.5} />
|
||||||
<Text className="ml-2 text-white text-[11px] font-bold uppercase tracking-widest">
|
<Text className="ml-2 text-white text-[12px] font-black uppercase tracking-widest">
|
||||||
Share
|
Share SMS
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<ShadowWrapper>
|
<ShadowWrapper>
|
||||||
<Button
|
<Button
|
||||||
className=" flex-1 mb-4 h-11 rounded-[6px] bg-card border border-border"
|
className=" flex-1 mb-4 h-12 rounded-[10px] bg-card border border-border"
|
||||||
onPress={() => {}}
|
onPress={() => {}}
|
||||||
>
|
>
|
||||||
<Download color="#0f172a" size={14} strokeWidth={2.5} />
|
<Download color="#0f172a" size={16} strokeWidth={2.5} />
|
||||||
<Text className="ml-2 text-foreground text-[11px] font-bold uppercase tracking-widest">
|
<Text className="ml-2 text-foreground text-[12px] font-black uppercase tracking-widest">
|
||||||
PDF
|
Get PDF
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</ShadowWrapper>
|
</ShadowWrapper>
|
||||||
|
|
|
||||||
106
app/login.tsx
106
app/login.tsx
|
|
@ -14,19 +14,36 @@ import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User } from "@/lib/icons";
|
import { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User, Globe } from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { api, BASE_URL } from "@/lib/api";
|
import { api, BASE_URL, rbacApi } from "@/lib/api";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { useLanguageStore, AppLanguage } from "@/lib/language-store";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import { LanguageModal } from "@/components/LanguageModal";
|
||||||
|
import {
|
||||||
|
GoogleSignin,
|
||||||
|
statusCodes,
|
||||||
|
} from "@react-native-google-signin/google-signin";
|
||||||
|
|
||||||
|
GoogleSignin.configure({
|
||||||
|
webClientId:
|
||||||
|
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com",
|
||||||
|
iosClientId:
|
||||||
|
"1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", // Placeholder: replace with your actual iOS Client ID from Google Cloud Console
|
||||||
|
offlineAccess: true,
|
||||||
|
});
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const setAuth = useAuthStore((state) => state.setAuth);
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
const isDark = colorScheme === "dark";
|
const isDark = colorScheme === "dark";
|
||||||
|
const { language, setLanguage } = useLanguageStore();
|
||||||
|
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||||
|
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
@ -53,8 +70,16 @@ export default function LoginScreen() {
|
||||||
// Using the new api.auth.login which is powered by simple-api
|
// Using the new api.auth.login which is powered by simple-api
|
||||||
const response = await api.auth.login({ body: payload });
|
const response = await api.auth.login({ body: payload });
|
||||||
|
|
||||||
// Store user, access token, and refresh token
|
// Store user, access token, refresh token, and permissions
|
||||||
setAuth(response.user, response.accessToken, response.refreshToken);
|
// // Fetch roles to get permissions
|
||||||
|
// const rolesResponse = await rbacApi.roles();
|
||||||
|
// const userRole = response.user.role;
|
||||||
|
// const roleData = rolesResponse.find((r: any) => r.role === userRole);
|
||||||
|
// const permissions = roleData ? roleData.permissions : [];
|
||||||
|
const permissions: string[] = [];
|
||||||
|
|
||||||
|
// Store user, access token, refresh token, and permissions
|
||||||
|
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
|
||||||
toast.success("Welcome Back!", "You have successfully logged in.");
|
toast.success("Welcome Back!", "You have successfully logged in.");
|
||||||
|
|
||||||
// Explicitly navigate to home
|
// Explicitly navigate to home
|
||||||
|
|
@ -67,35 +92,71 @@ export default function LoginScreen() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleLogin = async () => {
|
const handleGoogleLogin = async () => {
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
// Hit api.auth.google directly — that's it
|
setLoading(true);
|
||||||
const response = await api.auth.google();
|
await GoogleSignin.hasPlayServices();
|
||||||
setAuth(response.user, response.accessToken, response.refreshToken);
|
const userInfo = await GoogleSignin.signIn();
|
||||||
|
|
||||||
|
// In newer versions of the library, the response is in data
|
||||||
|
// If using idToken, ensure you configured webClientId
|
||||||
|
const idToken = userInfo.data?.idToken || (userInfo as any).idToken;
|
||||||
|
|
||||||
|
if (!idToken) {
|
||||||
|
throw new Error("Failed to obtain Google ID Token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send idToken to our new consolidated endpoint
|
||||||
|
const response = await api.auth.googleMobile({ body: { idToken } });
|
||||||
|
|
||||||
|
// Fetch roles to get permissions
|
||||||
|
// const rolesResponse = await rbacApi.roles();
|
||||||
|
// const userRole = response.user.role;
|
||||||
|
// const roleData = rolesResponse.find((r: any) => r.role === userRole);
|
||||||
|
// const permissions = roleData ? roleData.permissions : [];
|
||||||
|
const permissions: string[] = [];
|
||||||
|
|
||||||
|
setAuth(response.user, response.accessToken, response.refreshToken, permissions);
|
||||||
toast.success("Welcome!", "Signed in with Google.");
|
toast.success("Welcome!", "Signed in with Google.");
|
||||||
nav.go("(tabs)");
|
nav.go("(tabs)");
|
||||||
} catch (err: any) {
|
} catch (error: any) {
|
||||||
console.error("[Login] Google Login Error:", err);
|
if (error.code === statusCodes.SIGN_IN_CANCELLED) {
|
||||||
|
// User cancelled the login flow
|
||||||
|
} else if (error.code === statusCodes.IN_PROGRESS) {
|
||||||
|
toast.error("Login in progress", "Please wait...");
|
||||||
|
} else if (error.code === statusCodes.PLAY_SERVICES_NOT_AVAILABLE) {
|
||||||
|
toast.error("Play Services", "Google Play Services not available");
|
||||||
|
} else {
|
||||||
|
console.error("[Login] Google Error:", error);
|
||||||
toast.error(
|
toast.error(
|
||||||
"Google Login Failed",
|
"Google Login Failed",
|
||||||
err.message || "An unexpected error occurred.",
|
error.message || "An error occurred",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
contentContainerStyle={{ padding: 24, paddingTop: 60 }}
|
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 10 }}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
|
<View className="flex-row justify-end mb-4">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setLanguageModalVisible(true)}
|
||||||
|
className="p-2 rounded-full bg-card border border-border"
|
||||||
|
>
|
||||||
|
<Globe color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Logo / Branding */}
|
{/* Logo / Branding */}
|
||||||
<View className="items-center mb-10">
|
<View className="items-center mb-10">
|
||||||
<Text variant="h2" className="mt-6 font-bold text-foreground">
|
<Text variant="h2" className="mt-6 font-bold text-foreground">
|
||||||
|
|
@ -112,12 +173,12 @@ export default function LoginScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Email or Phone Number
|
Email or Phone Number
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground"
|
className="flex-1 ml-3 text-foreground"
|
||||||
placeholder="john@example.com or +251..."
|
placeholder="john@example.com or +251..."
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={identifier}
|
value={identifier}
|
||||||
onChangeText={setIdentifier}
|
onChangeText={setIdentifier}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|
@ -129,12 +190,12 @@ export default function LoginScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Password
|
Password
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground"
|
className="flex-1 ml-3 text-foreground"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
secureTextEntry={!showPassword}
|
secureTextEntry={!showPassword}
|
||||||
|
|
@ -150,7 +211,7 @@ export default function LoginScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="h-14 bg-primary rounded-[6px] shadow-lg shadow-primary/30 mt-2"
|
className="h-10 bg-primary rounded-[6px] shadow-lg shadow-primary/30 mt-2"
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
|
@ -184,7 +245,7 @@ export default function LoginScreen() {
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleGoogleLogin}
|
onPress={handleGoogleLogin}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex-1 h-14 border border-border rounded-[6px] items-center justify-center flex-row bg-card"
|
className="flex-1 h-10 border border-border rounded-[6px] items-center justify-center flex-row bg-card"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color={isDark ? "white" : "black"} />
|
<ActivityIndicator color={isDark ? "white" : "black"} />
|
||||||
|
|
@ -215,6 +276,13 @@ export default function LoginScreen() {
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
<LanguageModal
|
||||||
|
visible={languageModalVisible}
|
||||||
|
current={language}
|
||||||
|
onSelect={(lang) => setLanguage(lang)}
|
||||||
|
onClose={() => setLanguageModalVisible(false)}
|
||||||
|
/>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,134 @@
|
||||||
import { View, ScrollView, Pressable } from "react-native";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { View, ActivityIndicator, FlatList, RefreshControl } from "react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Bell, Settings, ChevronRight } from "@/lib/icons";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
|
|
||||||
const MOCK_NOTIFICATIONS = [
|
type NotificationItem = {
|
||||||
{
|
id: string;
|
||||||
id: "1",
|
title?: string;
|
||||||
title: "Invoice reminder",
|
body?: string;
|
||||||
body: "Invoice #2 to Robin Murray is due in 2 days.",
|
message?: string;
|
||||||
time: "2h ago",
|
createdAt?: string;
|
||||||
read: false,
|
read?: boolean;
|
||||||
},
|
};
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
title: "Payment received",
|
|
||||||
body: "Payment of $500 received for Invoice #4.",
|
|
||||||
time: "1d ago",
|
|
||||||
read: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
title: "Proforma submission",
|
|
||||||
body: "Vendor A submitted a quote for Marketing Landing Page.",
|
|
||||||
time: "2d ago",
|
|
||||||
read: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function NotificationsScreen() {
|
export default function NotificationsScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const [items, setItems] = useState<NotificationItem[]>([]);
|
||||||
return (
|
const [loading, setLoading] = useState(true);
|
||||||
<ScrollView
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
className="flex-1 bg-background"
|
const [page, setPage] = useState(1);
|
||||||
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
const [hasMore, setHasMore] = useState(true);
|
||||||
>
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
<View className="mb-4 flex-row items-center justify-between">
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<Bell color="#18181b" size={22} strokeWidth={2} />
|
|
||||||
<Text className="text-xl font-semibold text-gray-900">
|
|
||||||
Notifications
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
className="flex-row items-center gap-1"
|
|
||||||
onPress={() => nav.go("notifications/settings")}
|
|
||||||
>
|
|
||||||
<Settings color="#ea580c" size={18} strokeWidth={2} />
|
|
||||||
<Text className="text-primary font-medium">Settings</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{MOCK_NOTIFICATIONS.map((n) => (
|
const fetchNotifications = useCallback(
|
||||||
<Card
|
async (pageNum: number, mode: "initial" | "refresh" | "more") => {
|
||||||
key={n.id}
|
try {
|
||||||
className={`mb-2 ${!n.read ? "border-primary/30" : ""}`}
|
if (mode === "initial") setLoading(true);
|
||||||
>
|
if (mode === "refresh") setRefreshing(true);
|
||||||
|
if (mode === "more") setLoadingMore(true);
|
||||||
|
|
||||||
|
const res = await (api as any).notifications.getAll({
|
||||||
|
query: { page: pageNum, limit: 20 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = (res?.data ?? []) as NotificationItem[];
|
||||||
|
if (mode === "more") {
|
||||||
|
setItems((prev) => [...prev, ...next]);
|
||||||
|
} else {
|
||||||
|
setItems(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMore(Boolean(res?.meta?.hasNextPage));
|
||||||
|
setPage(pageNum);
|
||||||
|
} catch (e) {
|
||||||
|
setHasMore(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotifications(1, "initial");
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
const onRefresh = () => fetchNotifications(1, "refresh");
|
||||||
|
const onEndReached = () => {
|
||||||
|
if (!loading && !loadingMore && hasMore) fetchNotifications(page + 1, "more");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: NotificationItem }) => {
|
||||||
|
const message = item.body ?? item.message ?? "";
|
||||||
|
const time = item.createdAt
|
||||||
|
? new Date(item.createdAt).toLocaleString()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-2">
|
||||||
<CardContent className="py-3">
|
<CardContent className="py-3">
|
||||||
<Text className="font-semibold text-gray-900">{n.title}</Text>
|
<Text className="font-semibold text-foreground">
|
||||||
<Text className="text-muted-foreground mt-1 text-sm">{n.body}</Text>
|
{item.title ?? "Notification"}
|
||||||
<Text className="text-muted-foreground mt-1 text-xs">{n.time}</Text>
|
</Text>
|
||||||
|
{message ? (
|
||||||
|
<Text className="text-muted-foreground mt-1 text-sm">{message}</Text>
|
||||||
|
) : null}
|
||||||
|
{time ? (
|
||||||
|
<Text className="text-muted-foreground mt-1 text-xs">{time}</Text>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
</ScrollView>
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader
|
||||||
|
showBack
|
||||||
|
title="Notifications"
|
||||||
|
rightAction="notificationsSettings"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(i) => i.id}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onEndReachedThreshold={0.4}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="px-[16px] py-6">
|
||||||
|
<EmptyState
|
||||||
|
title="No notifications"
|
||||||
|
description="You don't have any notifications yet."
|
||||||
|
hint="Pull to refresh to check for new notifications."
|
||||||
|
previewLines={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
loadingMore ? (
|
||||||
|
<View className="py-4">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,221 @@
|
||||||
import { View, ScrollView, Switch } from "react-native";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
ActivityIndicator,
|
||||||
|
TextInput,
|
||||||
|
useColorScheme,
|
||||||
|
Pressable,
|
||||||
|
} from "react-native";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { useState } from "react";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Bell, CalendarSearch, FileText, Newspaper, ChevronRight } from "@/lib/icons";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
|
||||||
|
type NotificationSettings = {
|
||||||
|
id: string;
|
||||||
|
invoiceReminders: boolean;
|
||||||
|
daysBeforeDueDate: number;
|
||||||
|
newsAlerts: boolean;
|
||||||
|
reportReady: boolean;
|
||||||
|
userId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function NotificationSettingsScreen() {
|
export default function NotificationSettingsScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const [invoiceReminders, setInvoiceReminders] = useState(true);
|
const colorScheme = useColorScheme();
|
||||||
const [daysBeforeDue, setDaysBeforeDue] = useState(2);
|
const isDark = colorScheme === "dark";
|
||||||
const [newsAlerts, setNewsAlerts] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [reportReady, setReportReady] = useState(true);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<NotificationSettings | null>(null);
|
||||||
|
|
||||||
|
const [invoiceReminders, setInvoiceReminders] = useState(false);
|
||||||
|
const [daysBeforeDueDate, setDaysBeforeDueDate] = useState("0");
|
||||||
|
const [newsAlerts, setNewsAlerts] = useState(false);
|
||||||
|
const [reportReady, setReportReady] = useState(false);
|
||||||
|
const [daysModalVisible, setDaysModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const daysOptions = [
|
||||||
|
{ label: "1 day", value: "1" },
|
||||||
|
{ label: "3 days", value: "3" },
|
||||||
|
{ label: "7 days", value: "7" },
|
||||||
|
{ label: "14 days", value: "14" },
|
||||||
|
{ label: "30 days", value: "30" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await (api as any).notifications.settings();
|
||||||
|
const data = res?.data ?? res;
|
||||||
|
setSettings(data);
|
||||||
|
setInvoiceReminders(Boolean(data?.invoiceReminders));
|
||||||
|
setNewsAlerts(Boolean(data?.newsAlerts));
|
||||||
|
setReportReady(Boolean(data?.reportReady));
|
||||||
|
setDaysBeforeDueDate(String(data?.daysBeforeDueDate ?? 0));
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings();
|
||||||
|
}, [loadSettings]);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api.notifications.update({
|
||||||
|
body: {
|
||||||
|
invoiceReminders,
|
||||||
|
daysBeforeDueDate: parseInt(daysBeforeDueDate),
|
||||||
|
newsAlerts,
|
||||||
|
reportReady,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
nav.back();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader showBack title="Notification settings" />
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1 bg-background"
|
contentContainerStyle={{ padding: 16, paddingBottom: 110 }}
|
||||||
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<Card className="mb-4">
|
<View className="mb-5">
|
||||||
<CardHeader>
|
<Text variant="muted" className="text-xs font-semibold mb-2 px-1">
|
||||||
<CardTitle>Notification settings</CardTitle>
|
Preferences
|
||||||
</CardHeader>
|
</Text>
|
||||||
<CardContent className="gap-4">
|
|
||||||
<View className="flex-row items-center justify-between">
|
<Card className="overflow-hidden">
|
||||||
<Text className="text-gray-900">Invoice reminders</Text>
|
<CardContent className="p-0">
|
||||||
|
<View className="flex-row items-center px-4 py-3 border-b border-border/40">
|
||||||
|
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||||
|
<Bell size={17} color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-medium">
|
||||||
|
Invoice reminders
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5">
|
||||||
|
Get reminders before invoices are due
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={invoiceReminders}
|
value={invoiceReminders}
|
||||||
onValueChange={setInvoiceReminders}
|
onValueChange={setInvoiceReminders}
|
||||||
|
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
|
||||||
|
thumbColor="#ffffff"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row items-center justify-between">
|
|
||||||
<Text className="text-gray-900">News & announcements</Text>
|
<View className="px-4 py-3 border-b border-border/40">
|
||||||
<Switch value={newsAlerts} onValueChange={setNewsAlerts} />
|
<View className="flex-row items-center mb-2">
|
||||||
|
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||||
|
<CalendarSearch size={17} color="#ea580c" />
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row items-center justify-between">
|
<View className="flex-1">
|
||||||
<Text className="text-gray-900">Report ready</Text>
|
<Text className="text-foreground font-medium">
|
||||||
<Switch value={reportReady} onValueChange={setReportReady} />
|
Days before due date
|
||||||
|
</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5">
|
||||||
|
Currently: {daysBeforeDueDate} days
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => setDaysModalVisible(true)}>
|
||||||
|
<ChevronRight size={18} color="#ea580c" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center px-4 py-3 border-b border-border/40">
|
||||||
|
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||||
|
<Newspaper size={17} color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-medium">News alerts</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5">
|
||||||
|
Product updates and announcements
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={newsAlerts}
|
||||||
|
onValueChange={setNewsAlerts}
|
||||||
|
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
|
||||||
|
thumbColor="#ffffff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center px-4 py-3">
|
||||||
|
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
|
||||||
|
<FileText size={17} color="#ea580c" />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-foreground font-medium">Report ready</Text>
|
||||||
|
<Text variant="muted" className="text-xs mt-0.5">
|
||||||
|
Notify when reports are generated
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={reportReady}
|
||||||
|
onValueChange={setReportReady}
|
||||||
|
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
|
||||||
|
thumbColor="#ffffff"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</View>
|
||||||
<Button variant="outline" onPress={() => nav.back()}>
|
|
||||||
<Text className="font-medium">Back</Text>
|
|
||||||
</Button>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className="absolute bottom-0 pb-10 left-0 right-0 p-4 bg-background border-t border-border">
|
||||||
|
<Button className="bg-primary" onPress={onSave} disabled={saving || loading}>
|
||||||
|
<Text className="text-white font-semibold">
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={daysModalVisible}
|
||||||
|
title="Select Days"
|
||||||
|
onClose={() => setDaysModalVisible(false)}
|
||||||
|
>
|
||||||
|
{daysOptions.map((option) => (
|
||||||
|
<SelectOption
|
||||||
|
key={option.value}
|
||||||
|
label={option.label}
|
||||||
|
value={option.value}
|
||||||
|
selected={option.value === daysBeforeDueDate}
|
||||||
|
onSelect={(value: string) => {
|
||||||
|
setDaysBeforeDueDate(value);
|
||||||
|
setDaysModalVisible(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
795
app/payment-requests/create.tsx
Normal file
795
app/payment-requests/create.tsx
Normal file
|
|
@ -0,0 +1,795 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { colorScheme, useColorScheme } from "nativewind";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
CalendarSearch,
|
||||||
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
|
Send,
|
||||||
|
Trash2,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
|
||||||
|
type Item = { id: number; description: string; qty: string; price: string };
|
||||||
|
|
||||||
|
type Account = {
|
||||||
|
id: number;
|
||||||
|
bankName: string;
|
||||||
|
accountName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
input: {
|
||||||
|
height: 44,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
inputCenter: {
|
||||||
|
height: 44,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function useInputColors() {
|
||||||
|
const { colorScheme: scheme } = useColorScheme();
|
||||||
|
const dark = scheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
placeholder: "rgba(100,116,139,0.45)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
numeric = false,
|
||||||
|
center = false,
|
||||||
|
flex,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
numeric?: boolean;
|
||||||
|
center?: boolean;
|
||||||
|
flex?: number;
|
||||||
|
}) {
|
||||||
|
const c = useInputColors();
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={flex != null ? { flex } : undefined}>
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
center ? S.inputCenter : S.input,
|
||||||
|
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
|
||||||
|
]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType={numeric ? "numeric" : "default"}
|
||||||
|
autoCorrect={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
children,
|
||||||
|
noMargin,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
noMargin?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3"}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCIES = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
|
||||||
|
const STATUSES = ["DRAFT", "PENDING", "PAID", "CANCELLED"];
|
||||||
|
|
||||||
|
export default function CreatePaymentRequestScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [paymentRequestNumber, setPaymentRequestNumber] = useState("");
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [customerId, setCustomerId] = useState("");
|
||||||
|
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [currency, setCurrency] = useState("USD");
|
||||||
|
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const [taxAmount, setTaxAmount] = useState("0");
|
||||||
|
const [discountAmount, setDiscountAmount] = useState("0");
|
||||||
|
|
||||||
|
const [issueDate, setIssueDate] = useState(
|
||||||
|
new Date().toISOString().split("T")[0],
|
||||||
|
);
|
||||||
|
const [dueDate, setDueDate] = useState("");
|
||||||
|
|
||||||
|
const [paymentId, setPaymentId] = useState("");
|
||||||
|
const [status, setStatus] = useState("DRAFT");
|
||||||
|
|
||||||
|
const [items, setItems] = useState<Item[]>([
|
||||||
|
{ id: 1, description: "", qty: "1", price: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
bankName: "",
|
||||||
|
accountName: "",
|
||||||
|
accountNumber: "",
|
||||||
|
currency: "ETB",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const c = useInputColors();
|
||||||
|
|
||||||
|
const [showCurrency, setShowCurrency] = useState(false);
|
||||||
|
const [showIssueDate, setShowIssueDate] = useState(false);
|
||||||
|
const [showDueDate, setShowDueDate] = useState(false);
|
||||||
|
const [showStatus, setShowStatus] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const random = Math.floor(1000 + Math.random() * 9000);
|
||||||
|
setPaymentRequestNumber(`PAYREQ-${year}-${random}`);
|
||||||
|
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 30);
|
||||||
|
setDueDate(d.toISOString().split("T")[0]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateItem = (id: number, field: keyof Item, value: string) =>
|
||||||
|
setItems((prev) =>
|
||||||
|
prev.map((it) => (it.id === id ? { ...it, [field]: value } : it)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addItem = () =>
|
||||||
|
setItems((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ id: Date.now(), description: "", qty: "1", price: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeItem = (id: number) => {
|
||||||
|
if (items.length > 1) setItems((prev) => prev.filter((it) => it.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAccount = (id: number, field: keyof Account, value: string) =>
|
||||||
|
setAccounts((prev) =>
|
||||||
|
prev.map((acc) => (acc.id === id ? { ...acc, [field]: value } : acc)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const addAccount = () =>
|
||||||
|
setAccounts((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
bankName: "",
|
||||||
|
accountName: "",
|
||||||
|
accountNumber: "",
|
||||||
|
currency: "ETB",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeAccount = (id: number) => {
|
||||||
|
if (accounts.length > 1)
|
||||||
|
setAccounts((prev) => prev.filter((acc) => acc.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtotal = useMemo(
|
||||||
|
() =>
|
||||||
|
items.reduce(
|
||||||
|
(sum, item) =>
|
||||||
|
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const computedTotal = useMemo(
|
||||||
|
() => subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0),
|
||||||
|
[subtotal, taxAmount, discountAmount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!customerName) {
|
||||||
|
toast.error("Validation Error", "Please enter a customer name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedPhone = customerPhone.startsWith("+")
|
||||||
|
? customerPhone
|
||||||
|
: customerPhone.length > 0
|
||||||
|
? `+251${customerPhone}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
paymentRequestNumber,
|
||||||
|
customerName,
|
||||||
|
customerEmail,
|
||||||
|
customerPhone: formattedPhone,
|
||||||
|
amount: amount ? Number(amount) : Number(computedTotal.toFixed(2)),
|
||||||
|
currency,
|
||||||
|
issueDate: new Date(issueDate).toISOString(),
|
||||||
|
dueDate: new Date(dueDate).toISOString(),
|
||||||
|
description: description || `Payment request for ${customerName}`,
|
||||||
|
notes,
|
||||||
|
taxAmount: parseFloat(taxAmount) || 0,
|
||||||
|
discountAmount: parseFloat(discountAmount) || 0,
|
||||||
|
status,
|
||||||
|
paymentId,
|
||||||
|
accounts: accounts.map((a) => ({
|
||||||
|
bankName: a.bankName,
|
||||||
|
accountName: a.accountName,
|
||||||
|
accountNumber: a.accountNumber,
|
||||||
|
currency: a.currency,
|
||||||
|
})),
|
||||||
|
items: items.map((i) => ({
|
||||||
|
description: i.description || "Item",
|
||||||
|
quantity: parseFloat(i.qty) || 0,
|
||||||
|
unitPrice: parseFloat(i.price) || 0,
|
||||||
|
total: Number(
|
||||||
|
((parseFloat(i.qty) || 0) * (parseFloat(i.price) || 0)).toFixed(2),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
customerId: customerId || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await api.paymentRequests.create({ body });
|
||||||
|
toast.success("Success", "Payment request created successfully!");
|
||||||
|
nav.back();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[PaymentRequestCreate] Error:", err);
|
||||||
|
toast.error("Error", err?.message || "Failed to create payment request");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title="Create Payment Request" showBack />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 30 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<Label>General Information</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
|
<Field
|
||||||
|
label="Payment Request Number"
|
||||||
|
value={paymentRequestNumber}
|
||||||
|
onChangeText={setPaymentRequestNumber}
|
||||||
|
placeholder="e.g. PAYREQ-2024-001"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="e.g. Payment request for services"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<Label>Customer Details</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
|
<Field
|
||||||
|
label="Customer Name"
|
||||||
|
value={customerName}
|
||||||
|
onChangeText={setCustomerName}
|
||||||
|
placeholder="e.g. Acme Corporation"
|
||||||
|
/>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Email"
|
||||||
|
value={customerEmail}
|
||||||
|
onChangeText={setCustomerEmail}
|
||||||
|
placeholder="billing@acme.com"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Phone"
|
||||||
|
value={customerPhone}
|
||||||
|
onChangeText={setCustomerPhone}
|
||||||
|
placeholder="+251..."
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Field
|
||||||
|
label="Customer ID"
|
||||||
|
value={customerId}
|
||||||
|
onChangeText={setCustomerId}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<Label>Schedule & Currency</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Issue Date
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowIssueDate(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-medium" style={{ color: c.text }}>
|
||||||
|
{issueDate}
|
||||||
|
</Text>
|
||||||
|
<CalendarSearch size={14} color="#ea580c" strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Due Date
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowDueDate(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-medium" style={{ color: c.text }}>
|
||||||
|
{dueDate || "Select Date"}
|
||||||
|
</Text>
|
||||||
|
<Calendar size={14} color="#ea580c" strokeWidth={2.5} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Currency
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowCurrency(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-bold" style={{ color: c.text }}>
|
||||||
|
{currency}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5 "
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowStatus(true)}
|
||||||
|
className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between"
|
||||||
|
style={{ backgroundColor: c.bg, borderColor: c.border }}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-bold" style={{ color: c.text }}>
|
||||||
|
{status}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={c.text} strokeWidth={3} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Amount"
|
||||||
|
value={amount}
|
||||||
|
onChangeText={setAmount}
|
||||||
|
placeholder="1500"
|
||||||
|
numeric
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Payment ID"
|
||||||
|
value={paymentId}
|
||||||
|
onChangeText={setPaymentId}
|
||||||
|
placeholder="PAY-123456"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<Label noMargin>Items</Label>
|
||||||
|
<Pressable
|
||||||
|
onPress={addItem}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1 rounded-[6px] bg-primary/10 border border-primary/20"
|
||||||
|
>
|
||||||
|
<Plus color="#ea580c" size={10} strokeWidth={2.5} />
|
||||||
|
<Text className="text-primary text-[8px] font-bold uppercase tracking-widest">
|
||||||
|
Add Item
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-3 mb-5">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<ShadowWrapper key={item.id}>
|
||||||
|
<View className="bg-card rounded-[6px] p-4">
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] font-bold uppercase tracking-wide opacity-50"
|
||||||
|
>
|
||||||
|
Item {index + 1}
|
||||||
|
</Text>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<Pressable onPress={() => removeItem(item.id)} hitSlop={8}>
|
||||||
|
<Trash2 color="#ef4444" size={13} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
placeholder="e.g. Web Development Service"
|
||||||
|
value={item.description}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "description", v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3 mt-4">
|
||||||
|
<Field
|
||||||
|
label="Qty"
|
||||||
|
placeholder="1"
|
||||||
|
numeric
|
||||||
|
center
|
||||||
|
value={item.qty}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "qty", v)}
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Unit Price"
|
||||||
|
placeholder="0.00"
|
||||||
|
numeric
|
||||||
|
value={item.price}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "price", v)}
|
||||||
|
flex={2}
|
||||||
|
/>
|
||||||
|
<View className="flex-1 items-end justify-end pb-1">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[9px] uppercase font-bold opacity-40"
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text variant="p" className="text-foreground font-bold text-sm">
|
||||||
|
{currency}
|
||||||
|
{(
|
||||||
|
(parseFloat(item.qty) || 0) *
|
||||||
|
(parseFloat(item.price) || 0)
|
||||||
|
).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
|
<Label noMargin>Accounts</Label>
|
||||||
|
<Pressable
|
||||||
|
onPress={addAccount}
|
||||||
|
className="flex-row items-center gap-1 px-3 py-1 rounded-[6px] bg-primary/10 border border-primary/20"
|
||||||
|
>
|
||||||
|
<Plus color="#ea580c" size={10} strokeWidth={2.5} />
|
||||||
|
<Text className="text-primary text-[8px] font-bold uppercase tracking-widest">
|
||||||
|
Add Account
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-3 mb-5">
|
||||||
|
{accounts.map((acc, index) => (
|
||||||
|
<ShadowWrapper key={acc.id}>
|
||||||
|
<View className="bg-card rounded-[6px] p-4">
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] font-bold uppercase tracking-wide opacity-50"
|
||||||
|
>
|
||||||
|
Account {index + 1}
|
||||||
|
</Text>
|
||||||
|
{accounts.length > 1 && (
|
||||||
|
<Pressable onPress={() => removeAccount(acc.id)} hitSlop={8}>
|
||||||
|
<Trash2 color="#ef4444" size={13} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-4">
|
||||||
|
<Field
|
||||||
|
label="Bank Name"
|
||||||
|
value={acc.bankName}
|
||||||
|
onChangeText={(v) => updateAccount(acc.id, "bankName", v)}
|
||||||
|
placeholder="e.g. Yaltopia Bank"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Account Name"
|
||||||
|
value={acc.accountName}
|
||||||
|
onChangeText={(v) => updateAccount(acc.id, "accountName", v)}
|
||||||
|
placeholder="e.g. Yaltopia Tech PLC"
|
||||||
|
/>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Account Number"
|
||||||
|
value={acc.accountNumber}
|
||||||
|
onChangeText={(v) =>
|
||||||
|
updateAccount(acc.id, "accountNumber", v)
|
||||||
|
}
|
||||||
|
placeholder="123456789"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Currency"
|
||||||
|
value={acc.currency}
|
||||||
|
onChangeText={(v) => updateAccount(acc.id, "currency", v)}
|
||||||
|
placeholder="ETB"
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Label>Totals & Taxes</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-5 gap-3">
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">
|
||||||
|
Subtotal
|
||||||
|
</Text>
|
||||||
|
<Text variant="p" className="text-foreground font-bold">
|
||||||
|
{currency} {subtotal.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-4">
|
||||||
|
<Field
|
||||||
|
label="Tax"
|
||||||
|
value={taxAmount}
|
||||||
|
onChangeText={setTaxAmount}
|
||||||
|
placeholder="0"
|
||||||
|
numeric
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Discount"
|
||||||
|
value={discountAmount}
|
||||||
|
onChangeText={setDiscountAmount}
|
||||||
|
placeholder="0"
|
||||||
|
numeric
|
||||||
|
flex={1}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<ShadowWrapper>
|
||||||
|
<View className="bg-card rounded-[6px] p-4 mb-6">
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
S.input,
|
||||||
|
{
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
borderColor: c.border,
|
||||||
|
color: c.text,
|
||||||
|
height: 80,
|
||||||
|
textAlignVertical: "top",
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder="e.g. Payment terms: Net 30"
|
||||||
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
multiline
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
<View className="border border-border/60 rounded-[12px] p-5 mb-6">
|
||||||
|
<View className="flex-row justify-between items-center mb-5">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="font-bold text-xs uppercase tracking-widest opacity-60"
|
||||||
|
>
|
||||||
|
Total Amount
|
||||||
|
</Text>
|
||||||
|
<Text variant="h3" className="text-primary font-black">
|
||||||
|
{currency}{" "}
|
||||||
|
{(amount ? Number(amount) : computedTotal).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1 h-10 rounded-[6px] border border-border"
|
||||||
|
onPress={() => nav.back()}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<Text className="text-foreground font-bold text-xs uppercase tracking-tighter">
|
||||||
|
Discard
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="flex-1 h-10 rounded-[6px] bg-primary"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<ActivityIndicator color="white" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send color="white" size={14} strokeWidth={2.5} />
|
||||||
|
<Text className="text-white font-bold text-sm">
|
||||||
|
Create Request
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showCurrency}
|
||||||
|
onClose={() => setShowCurrency(false)}
|
||||||
|
title="Select Currency"
|
||||||
|
>
|
||||||
|
{CURRENCIES.map((curr) => (
|
||||||
|
<SelectOption
|
||||||
|
key={curr}
|
||||||
|
label={curr}
|
||||||
|
value={curr}
|
||||||
|
selected={currency === curr}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setCurrency(v);
|
||||||
|
setShowCurrency(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showStatus}
|
||||||
|
onClose={() => setShowStatus(false)}
|
||||||
|
title="Select Status"
|
||||||
|
>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<SelectOption
|
||||||
|
key={s}
|
||||||
|
label={s}
|
||||||
|
value={s}
|
||||||
|
selected={status === s}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setStatus(v);
|
||||||
|
setShowStatus(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showIssueDate}
|
||||||
|
onClose={() => setShowIssueDate(false)}
|
||||||
|
title="Select Issue Date"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={issueDate}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setIssueDate(v);
|
||||||
|
setShowIssueDate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
<PickerModal
|
||||||
|
visible={showDueDate}
|
||||||
|
onClose={() => setShowDueDate(false)}
|
||||||
|
title="Select Due Date"
|
||||||
|
>
|
||||||
|
<CalendarGrid
|
||||||
|
selectedDate={dueDate}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setDueDate(v);
|
||||||
|
setShowDueDate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PickerModal>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
app/privacy.tsx
Normal file
81
app/privacy.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { View, ScrollView } from "react-native";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export default function PrivacyScreen() {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader title="Privacy Policy" showBack />
|
||||||
|
|
||||||
|
<ScrollView className="flex-1 px-5 pt-4" showsVerticalScrollIndicator={false}>
|
||||||
|
<Text variant="h4" className="text-foreground mb-4">
|
||||||
|
Privacy Policy
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
Last updated: March 10, 2026
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
1. Introduction
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
This Privacy Policy describes how we collect, use, and share your personal information when you use our mobile application ("App").
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
2. Information We Collect
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We may collect information about you in various ways, including:
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• Personal information you provide directly to us
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• Information we collect automatically when you use the App
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
• Information from third-party services
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
3. How We Use Your Information
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We use the information we collect to:
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• Provide and maintain our services
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• Process transactions and send related information
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
• Communicate with you about our services
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
4. Information Sharing
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We do not sell, trade, or otherwise transfer your personal information to third parties without your consent, except as described in this policy.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
5. Data Security
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We implement appropriate security measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
6. Contact Us
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
If you have any questions about this Privacy Policy, please contact us at privacy@example.com.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
app/profile.tsx
203
app/profile.tsx
|
|
@ -5,9 +5,7 @@ import {
|
||||||
Pressable,
|
Pressable,
|
||||||
Image,
|
Image,
|
||||||
Switch,
|
Switch,
|
||||||
Modal,
|
InteractionManager,
|
||||||
TouchableOpacity,
|
|
||||||
TouchableWithoutFeedback,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
|
@ -25,12 +23,16 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
Lock,
|
Lock,
|
||||||
|
Globe,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { saveTheme, AppTheme } from "@/lib/theme";
|
import { saveTheme, AppTheme } from "@/lib/theme";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
|
import { useLanguageStore, AppLanguage } from "@/lib/language-store";
|
||||||
|
import { LanguageModal } from "@/components/LanguageModal";
|
||||||
|
import { ThemeModal } from "@/components/ThemeModal";
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────
|
||||||
const AVATAR_FALLBACK_BASE =
|
const AVATAR_FALLBACK_BASE =
|
||||||
|
|
@ -45,65 +47,12 @@ const THEME_OPTIONS = [
|
||||||
|
|
||||||
type ThemeOption = (typeof THEME_OPTIONS)[number]["value"];
|
type ThemeOption = (typeof THEME_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
function ThemeSheet({
|
const LANGUAGE_OPTIONS = [
|
||||||
visible,
|
{ value: "en", label: "English" },
|
||||||
current,
|
{ value: "am", label: "Amharic" },
|
||||||
onSelect,
|
] as const;
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
visible: boolean;
|
|
||||||
current: ThemeOption;
|
|
||||||
onSelect: (v: ThemeOption) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent
|
|
||||||
animationType="slide"
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
|
||||||
<View className="flex-1 bg-black/40 justify-end" />
|
|
||||||
</TouchableWithoutFeedback>
|
|
||||||
|
|
||||||
<View className="bg-card rounded-t-[16px] pb-10 px-4 pt-4">
|
type LanguageOption = (typeof LANGUAGE_OPTIONS)[number]["value"];
|
||||||
{/* Handle */}
|
|
||||||
<View className="w-10 h-1 rounded-full bg-border self-center mb-5" />
|
|
||||||
|
|
||||||
<Text variant="p" className="text-foreground font-bold mb-4 px-1">
|
|
||||||
Appearance
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{THEME_OPTIONS.map((opt, i) => {
|
|
||||||
const selected = current === opt.value;
|
|
||||||
const isLast = i === THEME_OPTIONS.length - 1;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={opt.value}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
onPress={() => {
|
|
||||||
onSelect(opt.value);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
className={`flex-row items-center justify-between py-3.5 px-1 ${!isLast ? "border-b border-border/40" : ""}`}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
variant="p"
|
|
||||||
className={
|
|
||||||
selected ? "text-primary font-semibold" : "text-foreground"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</Text>
|
|
||||||
{selected && <View className="h-2 w-2 rounded-full bg-primary" />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared menu components ────────────────────────────────────────
|
// ── Shared menu components ────────────────────────────────────────
|
||||||
function MenuGroup({
|
function MenuGroup({
|
||||||
|
|
@ -167,7 +116,11 @@ function MenuItem({
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{right !== undefined ? right : <ChevronRight color="#000" size={17} />}
|
{right !== undefined ? (
|
||||||
|
right
|
||||||
|
) : (
|
||||||
|
<ChevronRight color={destructive ? "#ef4444" : "#94a3b8"} size={18} />
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -177,13 +130,17 @@ export default function ProfileScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
const { setColorScheme, colorScheme } = useColorScheme();
|
const { setColorScheme, colorScheme } = useColorScheme();
|
||||||
|
const { language, setLanguage } = useLanguageStore();
|
||||||
const [notifications, setNotifications] = useState(true);
|
const [notifications, setNotifications] = useState(true);
|
||||||
const [themeSheetVisible, setThemeSheetVisible] = useState(false);
|
const [themeSheetVisible, setThemeSheetVisible] = useState(false);
|
||||||
|
const [languageSheetVisible, setLanguageSheetVisible] = useState(false);
|
||||||
|
|
||||||
const currentTheme: ThemeOption = (colorScheme as ThemeOption) ?? "system";
|
const currentTheme: ThemeOption = (colorScheme as ThemeOption) ?? "system";
|
||||||
|
const currentLanguage: LanguageOption = (language as LanguageOption) ?? "en";
|
||||||
|
|
||||||
const handleThemeSelect = (val: AppTheme) => {
|
const handleThemeSelect = (val: AppTheme) => {
|
||||||
setColorScheme(val === "system" ? "system" : val);
|
// NativeWind 4 handles system/light/dark
|
||||||
|
setColorScheme(val);
|
||||||
saveTheme(val); // persist across restarts
|
saveTheme(val); // persist across restarts
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -195,7 +152,10 @@ export default function ProfileScreen() {
|
||||||
onPress={() => nav.back()}
|
onPress={() => nav.back()}
|
||||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
>
|
>
|
||||||
<ArrowLeft color="#0f172a" size={20} />
|
<ArrowLeft
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text variant="h4" className="text-foreground font-semibold">
|
<Text variant="h4" className="text-foreground font-semibold">
|
||||||
Profile
|
Profile
|
||||||
|
|
@ -205,7 +165,10 @@ export default function ProfileScreen() {
|
||||||
onPress={() => nav.go("edit-profile")}
|
onPress={() => nav.go("edit-profile")}
|
||||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
>
|
>
|
||||||
<User className="text-foreground" color="#000" size={18} />
|
<User
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -237,26 +200,40 @@ export default function ProfileScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Account */}
|
|
||||||
<MenuGroup label="Account">
|
<MenuGroup label="Account">
|
||||||
<MenuItem
|
{/* <MenuItem
|
||||||
icon={<CreditCard className="text-foreground" size={17} />}
|
icon={
|
||||||
|
<CreditCard
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Subscription"
|
label="Subscription"
|
||||||
sublabel="Pro Plan — active"
|
sublabel="Pro Plan — active"
|
||||||
onPress={() => {}}
|
onPress={() => {}}
|
||||||
/>
|
/> */}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<History className="text-foreground" size={17} />}
|
icon={
|
||||||
|
<History
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Transaction History"
|
label="Transaction History"
|
||||||
onPress={() => {}}
|
onPress={() => nav.go("history")}
|
||||||
isLast
|
isLast
|
||||||
/>
|
/>
|
||||||
</MenuGroup>
|
</MenuGroup>
|
||||||
|
|
||||||
{/* Preferences */}
|
{/* Preferences */}
|
||||||
<MenuGroup label="Preferences">
|
<MenuGroup label="Preferences">
|
||||||
<MenuItem
|
{/* <MenuItem
|
||||||
icon={<Bell className="text-foreground" size={17} />}
|
icon={
|
||||||
|
<Bell
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Push Notifications"
|
label="Push Notifications"
|
||||||
right={
|
right={
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -265,9 +242,14 @@ export default function ProfileScreen() {
|
||||||
trackColor={{ true: "#ea580c" }}
|
trackColor={{ true: "#ea580c" }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/> */}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<Settings className="text-foreground" size={17} />}
|
icon={
|
||||||
|
<Settings
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Appearance"
|
label="Appearance"
|
||||||
sublabel={
|
sublabel={
|
||||||
THEME_OPTIONS.find((o) => o.value === currentTheme)?.label ??
|
THEME_OPTIONS.find((o) => o.value === currentTheme)?.label ??
|
||||||
|
|
@ -276,7 +258,26 @@ export default function ProfileScreen() {
|
||||||
onPress={() => setThemeSheetVisible(true)}
|
onPress={() => setThemeSheetVisible(true)}
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<Lock className="text-foreground" size={17} />}
|
icon={
|
||||||
|
<Globe
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Language"
|
||||||
|
sublabel={
|
||||||
|
LANGUAGE_OPTIONS.find((o) => o.value === currentLanguage)?.label ??
|
||||||
|
"English"
|
||||||
|
}
|
||||||
|
onPress={() => setLanguageSheetVisible(true)}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
icon={
|
||||||
|
<Lock
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Security"
|
label="Security"
|
||||||
sublabel="PIN & Biometrics"
|
sublabel="PIN & Biometrics"
|
||||||
onPress={() => {}}
|
onPress={() => {}}
|
||||||
|
|
@ -287,19 +288,34 @@ export default function ProfileScreen() {
|
||||||
{/* Support & Legal */}
|
{/* Support & Legal */}
|
||||||
<MenuGroup label="Support & Legal">
|
<MenuGroup label="Support & Legal">
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<HelpCircle className="text-foreground" size={17} />}
|
icon={
|
||||||
|
<HelpCircle
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Help & Support"
|
label="Help & Support"
|
||||||
onPress={() => {}}
|
onPress={() => nav.go("help")}
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<ShieldCheck className="text-foreground" size={17} />}
|
icon={
|
||||||
|
<ShieldCheck
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Privacy Policy"
|
label="Privacy Policy"
|
||||||
onPress={() => {}}
|
onPress={() => nav.go("privacy")}
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<FileText className="text-foreground" size={17} />}
|
icon={
|
||||||
|
<FileText
|
||||||
|
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Terms of Use"
|
label="Terms of Use"
|
||||||
onPress={() => {}}
|
onPress={() => nav.go("terms")}
|
||||||
isLast
|
isLast
|
||||||
/>
|
/>
|
||||||
</MenuGroup>
|
</MenuGroup>
|
||||||
|
|
@ -308,10 +324,18 @@ export default function ProfileScreen() {
|
||||||
<ShadowWrapper>
|
<ShadowWrapper>
|
||||||
<View className="bg-card rounded-[10px] overflow-hidden">
|
<View className="bg-card rounded-[10px] overflow-hidden">
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={<LogOut color="#ef4444" size={17} />}
|
icon={
|
||||||
|
<LogOut
|
||||||
|
color="#ef4444"
|
||||||
|
size={17}
|
||||||
|
/>
|
||||||
|
}
|
||||||
label="Log Out"
|
label="Log Out"
|
||||||
destructive
|
destructive
|
||||||
onPress={logout}
|
onPress={async () => {
|
||||||
|
await logout();
|
||||||
|
nav.go("login");
|
||||||
|
}}
|
||||||
right={null}
|
right={null}
|
||||||
isLast
|
isLast
|
||||||
/>
|
/>
|
||||||
|
|
@ -320,12 +344,19 @@ export default function ProfileScreen() {
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Theme sheet */}
|
{/* Theme sheet */}
|
||||||
<ThemeSheet
|
<ThemeModal
|
||||||
visible={themeSheetVisible}
|
visible={themeSheetVisible}
|
||||||
current={currentTheme}
|
current={currentTheme}
|
||||||
onSelect={handleThemeSelect}
|
onSelect={(theme) => handleThemeSelect(theme)}
|
||||||
onClose={() => setThemeSheetVisible(false)}
|
onClose={() => setThemeSheetVisible(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LanguageModal
|
||||||
|
visible={languageSheetVisible}
|
||||||
|
current={currentLanguage}
|
||||||
|
onSelect={(lang) => setLanguage(lang)}
|
||||||
|
onClose={() => setLanguageSheetVisible(false)}
|
||||||
|
/>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Stack, useLocalSearchParams } from "expo-router";
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
@ -20,8 +21,38 @@ import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
|
|
||||||
|
const dummyData = {
|
||||||
|
id: "dummy-1",
|
||||||
|
proformaNumber: "PF-001",
|
||||||
|
customerName: "John Doe",
|
||||||
|
customerEmail: "john@example.com",
|
||||||
|
customerPhone: "+1234567890",
|
||||||
|
amount: { value: 1000, currency: "USD" },
|
||||||
|
currency: "USD",
|
||||||
|
issueDate: "2026-03-10T11:51:36.134Z",
|
||||||
|
dueDate: "2026-03-10T11:51:36.134Z",
|
||||||
|
description: "Dummy proforma",
|
||||||
|
notes: "Test notes",
|
||||||
|
taxAmount: { value: 100, currency: "USD" },
|
||||||
|
discountAmount: { value: 50, currency: "USD" },
|
||||||
|
pdfPath: "dummy.pdf",
|
||||||
|
userId: "user-1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
description: "Test item",
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: { value: 1000, currency: "USD" },
|
||||||
|
total: { value: 1000, currency: "USD" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: "2026-03-10T11:51:36.134Z",
|
||||||
|
updatedAt: "2026-03-10T11:51:36.134Z"
|
||||||
|
};
|
||||||
|
|
||||||
export default function ProformaDetailScreen() {
|
export default function ProformaDetailScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const router = useRouter();
|
||||||
const { id } = useLocalSearchParams();
|
const { id } = useLocalSearchParams();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -39,6 +70,7 @@ export default function ProformaDetailScreen() {
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[ProformaDetail] Error:", error);
|
console.error("[ProformaDetail] Error:", error);
|
||||||
toast.error("Error", "Failed to load proforma details");
|
toast.error("Error", "Failed to load proforma details");
|
||||||
|
setProforma(dummyData); // Use dummy data for testing
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -86,77 +118,76 @@ export default function ProformaDetailScreen() {
|
||||||
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Blue Summary Card */}
|
{/* Proforma Info Card */}
|
||||||
<Card className="overflow-hidden rounded-[6px] border-0 bg-primary mb-4">
|
<Card className="bg-card rounded-[12px] mb-4 border border-border">
|
||||||
<View className="p-5">
|
<View className="p-4">
|
||||||
<View className="flex-row items-center justify-between mb-3">
|
<View className="flex-row items-center gap-3 mb-3">
|
||||||
<View className="bg-white/20 p-1.5 rounded-[6px]">
|
<View className="bg-primary/10 p-2 rounded-[8px]">
|
||||||
<DraftingCompass color="white" size={16} strokeWidth={2.5} />
|
<DraftingCompass color="#ea580c" size={16} strokeWidth={2.5} />
|
||||||
</View>
|
</View>
|
||||||
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
|
||||||
<Text className="text-[10px] font-bold text-white uppercase tracking-widest">
|
Proforma Details
|
||||||
ACTIVE
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text variant="small" className="text-white/70 mb-0.5">
|
<View className="gap-2">
|
||||||
Customer: {proforma.customerName}
|
<View className="flex-row justify-between">
|
||||||
</Text>
|
<Text variant="muted" className="text-xs font-medium">Proforma Number</Text>
|
||||||
<Text variant="h3" className="text-white font-bold mb-3">
|
<Text className="text-foreground font-semibold text-sm">{proforma.proformaNumber}</Text>
|
||||||
{proforma.description || "Proforma Request"}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3">
|
|
||||||
<View className="flex-row items-center gap-1.5">
|
|
||||||
<Clock color="rgba(255,255,255,0.9)" size={12} />
|
|
||||||
<Text className="text-white/90 text-xs font-semibold">
|
|
||||||
Due {new Date(proforma.dueDate).toLocaleDateString()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View className="h-3 w-[1px] bg-white/60" />
|
<View className="flex-row justify-between">
|
||||||
<Text className="text-white/90 text-xs font-semibold">
|
<Text variant="muted" className="text-xs font-medium">Issued Date</Text>
|
||||||
{proforma.proformaNumber}
|
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.issueDate).toLocaleDateString()}</Text>
|
||||||
</Text>
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">Due Date</Text>
|
||||||
|
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.dueDate).toLocaleDateString()}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">Currency</Text>
|
||||||
|
<Text className="text-foreground font-semibold text-sm">{proforma.currency}</Text>
|
||||||
|
</View>
|
||||||
|
{proforma.description && (
|
||||||
|
<View className="mt-2">
|
||||||
|
<Text variant="muted" className="text-xs font-medium mb-1">Description</Text>
|
||||||
|
<Text className="text-foreground text-sm">{proforma.description}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Customer Info Strip (Added for functionality while keeping style) */}
|
{/* Customer Info Card */}
|
||||||
<Card className="bg-card rounded-[6px] mb-4">
|
<Card className="bg-card rounded-[12px] mb-4 border border-border">
|
||||||
<View className="flex-row px-4 py-2">
|
<View className="p-4">
|
||||||
<View className="flex-1 flex-row items-center">
|
<View className="flex-row items-center gap-3 mb-3">
|
||||||
<View className="flex-col">
|
<View className="bg-primary/10 p-2 rounded-[8px]">
|
||||||
<Text className="text-foreground text-[10px] opacity-60 uppercase font-bold">
|
<CheckCircle2 color="#ea580c" size={16} strokeWidth={2.5} />
|
||||||
Email
|
</View>
|
||||||
</Text>
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
|
||||||
<Text
|
Customer Information
|
||||||
variant="p"
|
|
||||||
className="text-foreground font-semibold text-xs"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{proforma.customerEmail || "N/A"}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-2">
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text variant="muted" className="text-xs font-medium">Name</Text>
|
||||||
|
<Text className="text-foreground font-semibold text-sm">{proforma.customerName}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="w-[1px] bg-border/70 mx-3" />
|
<View className="flex-row justify-between">
|
||||||
<View className="flex-1 flex-row items-center">
|
<Text variant="muted" className="text-xs font-medium">Email</Text>
|
||||||
<View className="flex-col">
|
<Text className="text-foreground font-semibold text-sm">{proforma.customerEmail || "N/A"}</Text>
|
||||||
<Text className="text-foreground text-[10px] opacity-60 uppercase font-bold">
|
</View>
|
||||||
Phone
|
<View className="flex-row justify-between">
|
||||||
</Text>
|
<Text variant="muted" className="text-xs font-medium">Phone</Text>
|
||||||
<Text
|
<Text className="text-foreground font-semibold text-sm">{proforma.customerPhone || "N/A"}</Text>
|
||||||
variant="p"
|
|
||||||
className="text-foreground font-semibold text-xs"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{proforma.customerPhone || "N/A"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Line Items Card */}
|
{/* Line Items Card */}
|
||||||
<Card className="bg-card rounded-[6px] mb-4">
|
<Card className="bg-card rounded-[6px] mb-4">
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
|
|
@ -271,24 +302,26 @@ export default function ProformaDetailScreen() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<View className="flex-row gap-3">
|
<View className="gap-3">
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 h-11 rounded-[6px] bg-primary"
|
className="h-12 rounded-[10px] bg-transparent border border-border"
|
||||||
|
onPress={() => router.push("/proforma/edit?id=" + proforma.id)}
|
||||||
|
>
|
||||||
|
<DraftingCompass color="#fff" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="ml-2 text-foreground font-black text-[12px] uppercase tracking-widest">
|
||||||
|
Edit
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
|
||||||
onPress={() => {}}
|
onPress={() => {}}
|
||||||
>
|
>
|
||||||
<Send color="#ffffff" size={14} strokeWidth={2.5} />
|
<Send color="#ffffff" size={16} strokeWidth={2.5} />
|
||||||
<Text className="ml-2 text-white font-bold text-[11px] uppercase tracking-widest">
|
<Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
|
||||||
Share
|
Share SMS
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1 h-11 rounded-[6px] bg-card border border-border"
|
|
||||||
onPress={() => nav.back()}
|
|
||||||
>
|
|
||||||
<Text className="text-foreground font-semibold text-[11px] uppercase tracking-widest">
|
|
||||||
Back
|
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,14 @@ import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { useSirouRouter } from "@sirou/react-native";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
import { AppRoutes } from "@/lib/routes";
|
import { AppRoutes } from "@/lib/routes";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useColorScheme } from "nativewind";
|
import { colorScheme, useColorScheme } from "nativewind";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
import { CalendarGrid } from "@/components/CalendarGrid";
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
import { StandardHeader } from "@/components/StandardHeader";
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
|
||||||
type Item = { id: number; description: string; qty: string; price: string };
|
type Item = { id: number; description: string; qty: string; price: string };
|
||||||
|
|
||||||
|
|
@ -82,6 +83,7 @@ function Field({
|
||||||
flex?: number;
|
flex?: number;
|
||||||
}) {
|
}) {
|
||||||
const c = useInputColors();
|
const c = useInputColors();
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
return (
|
return (
|
||||||
<View style={flex != null ? { flex } : undefined}>
|
<View style={flex != null ? { flex } : undefined}>
|
||||||
<Text
|
<Text
|
||||||
|
|
@ -96,7 +98,7 @@ function Field({
|
||||||
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
|
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
|
||||||
]}
|
]}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={c.placeholder}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={value}
|
value={value}
|
||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
keyboardType={numeric ? "numeric" : "default"}
|
keyboardType={numeric ? "numeric" : "default"}
|
||||||
|
|
@ -229,6 +231,8 @@ export default function CreateProformaScreen() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenWrapper className="bg-background">
|
<ScreenWrapper className="bg-background">
|
||||||
<Stack.Screen options={{ headerShown: false }} />
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
|
@ -491,7 +495,7 @@ export default function CreateProformaScreen() {
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
placeholder="e.g. Payment due within 30 days"
|
placeholder="e.g. Payment due within 30 days"
|
||||||
placeholderTextColor={c.placeholder}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={notes}
|
value={notes}
|
||||||
onChangeText={setNotes}
|
onChangeText={setNotes}
|
||||||
multiline
|
multiline
|
||||||
|
|
@ -500,7 +504,7 @@ export default function CreateProformaScreen() {
|
||||||
</ShadowWrapper>
|
</ShadowWrapper>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View className="border border-border/60 rounded-[12px] p-5 bg-primary/5 mb-6">
|
<View className="border border-border/60 rounded-[12px] p-5 mb-6">
|
||||||
<View className="flex-row justify-between items-center mb-5">
|
<View className="flex-row justify-between items-center mb-5">
|
||||||
<Text
|
<Text
|
||||||
variant="muted"
|
variant="muted"
|
||||||
|
|
@ -518,8 +522,8 @@ export default function CreateProformaScreen() {
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="flex-1 h-12 rounded-[6px] border-border bg-card"
|
className="flex-1 h-10 rounded-[6px] border border-border"
|
||||||
onPress={() => nav.back()}
|
onPress={() => nav.back()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
|
@ -528,7 +532,7 @@ export default function CreateProformaScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 h-12 rounded-[6px] bg-primary"
|
className="flex-1 h-10 rounded-[6px] bg-primary"
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
|
@ -536,8 +540,8 @@ export default function CreateProformaScreen() {
|
||||||
<ActivityIndicator color="white" size="small" />
|
<ActivityIndicator color="white" size="small" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send color="white" size={16} strokeWidth={2.5} />
|
<Send color="white" size={14} strokeWidth={2.5} />
|
||||||
<Text className="text-white font-bold text-xs uppercase tracking-tighter">
|
<Text className="text-white font-bold text-sm ">
|
||||||
Create Proforma
|
Create Proforma
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
584
app/proforma/edit.tsx
Normal file
584
app/proforma/edit.tsx
Normal file
|
|
@ -0,0 +1,584 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
ScrollView,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
StyleSheet,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
Plus,
|
||||||
|
Calendar,
|
||||||
|
ChevronDown,
|
||||||
|
CalendarSearch,
|
||||||
|
} from "@/lib/icons";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { CalendarGrid } from "@/components/CalendarGrid";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
|
||||||
|
type Item = { id: number; description: string; qty: string; price: string };
|
||||||
|
|
||||||
|
const S = StyleSheet.create({
|
||||||
|
input: {
|
||||||
|
height: 44,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
inputCenter: {
|
||||||
|
height: 44,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function useInputColors() {
|
||||||
|
const { colorScheme } = useColorScheme(); // Fix usage
|
||||||
|
const dark = colorScheme === "dark";
|
||||||
|
return {
|
||||||
|
bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)",
|
||||||
|
border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)",
|
||||||
|
text: dark ? "#f1f5f9" : "#0f172a",
|
||||||
|
placeholder: "rgba(100,116,139,0.45)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
numeric = false,
|
||||||
|
center = false,
|
||||||
|
flex,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
numeric?: boolean;
|
||||||
|
center?: boolean;
|
||||||
|
flex?: number;
|
||||||
|
}) {
|
||||||
|
const c = useInputColors();
|
||||||
|
const isDark = colorScheme.get() === "dark";
|
||||||
|
return (
|
||||||
|
<View style={flex != null ? { flex } : undefined}>
|
||||||
|
<Text
|
||||||
|
variant="small"
|
||||||
|
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
center ? S.inputCenter : S.input,
|
||||||
|
{ backgroundColor: c.bg, borderColor: c.border, color: c.text },
|
||||||
|
]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={c.placeholder}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
keyboardType={numeric ? "numeric" : "default"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditProformaScreen() {
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams();
|
||||||
|
const isEdit = !!id;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(isEdit);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [proformaNumber, setProformaNumber] = useState("");
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [customerEmail, setCustomerEmail] = useState("");
|
||||||
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
|
const [currency, setCurrency] = useState("USD");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [taxAmount, setTaxAmount] = useState("");
|
||||||
|
const [discountAmount, setDiscountAmount] = useState("");
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
const [issueDate, setIssueDate] = useState(new Date());
|
||||||
|
const [dueDate, setDueDate] = useState(new Date());
|
||||||
|
|
||||||
|
// Items
|
||||||
|
const [items, setItems] = useState<Item[]>([
|
||||||
|
{ id: 1, description: "", qty: "", price: "" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [currencyModal, setCurrencyModal] = useState(false);
|
||||||
|
const [issueModal, setIssueModal] = useState(false);
|
||||||
|
const [dueModal, setDueModal] = useState(false);
|
||||||
|
|
||||||
|
// Fetch existing data for edit
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit) {
|
||||||
|
fetchProforma();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchProforma = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await api.proforma.getById({ params: { id: id as string } });
|
||||||
|
// Prefill form
|
||||||
|
setProformaNumber(data.proformaNumber || "");
|
||||||
|
setCustomerName(data.customerName || "");
|
||||||
|
setCustomerEmail(data.customerEmail || "");
|
||||||
|
setCustomerPhone(data.customerPhone || "");
|
||||||
|
setCurrency(data.currency || "USD");
|
||||||
|
setDescription(data.description || "");
|
||||||
|
setNotes(data.notes || "");
|
||||||
|
setTaxAmount(String(data.taxAmount?.value || data.taxAmount || ""));
|
||||||
|
setDiscountAmount(String(data.discountAmount?.value || data.discountAmount || ""));
|
||||||
|
setIssueDate(new Date(data.issueDate));
|
||||||
|
setDueDate(new Date(data.dueDate));
|
||||||
|
setItems(
|
||||||
|
data.items?.map((item: any, idx: number) => ({
|
||||||
|
id: idx + 1,
|
||||||
|
description: item.description || "",
|
||||||
|
qty: String(item.quantity || ""),
|
||||||
|
price: String(item.unitPrice?.value || item.unitPrice || ""),
|
||||||
|
})) || [{ id: 1, description: "", qty: "", price: "" }]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error", "Failed to load proforma, using test data");
|
||||||
|
// For testing, set dummy data
|
||||||
|
setProformaNumber(dummyData.proformaNumber);
|
||||||
|
setCustomerName(dummyData.customerName);
|
||||||
|
setCustomerEmail(dummyData.customerEmail);
|
||||||
|
setCustomerPhone(dummyData.customerPhone);
|
||||||
|
setCurrency(dummyData.currency);
|
||||||
|
setDescription(dummyData.description);
|
||||||
|
setNotes(dummyData.notes);
|
||||||
|
setTaxAmount(String(dummyData.taxAmount?.value || dummyData.taxAmount || ""));
|
||||||
|
setDiscountAmount(String(dummyData.discountAmount?.value || dummyData.discountAmount || ""));
|
||||||
|
setIssueDate(new Date(dummyData.issueDate));
|
||||||
|
setDueDate(new Date(dummyData.dueDate));
|
||||||
|
setItems(
|
||||||
|
dummyData.items?.map((item: any, idx: number) => ({
|
||||||
|
id: idx + 1,
|
||||||
|
description: item.description || "",
|
||||||
|
qty: String(item.quantity || ""),
|
||||||
|
price: String(item.unitPrice?.value || item.unitPrice || ""),
|
||||||
|
})) || [{ id: 1, description: "", qty: "", price: "" }]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
const newId = Math.max(...items.map((i) => i.id)) + 1;
|
||||||
|
setItems([...items, { id: newId, description: "", qty: "", price: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (id: number) => {
|
||||||
|
if (items.length > 1) {
|
||||||
|
setItems(items.filter((i) => i.id !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (id: number, field: keyof Item, value: string) => {
|
||||||
|
setItems(
|
||||||
|
items.map((i) => (i.id === id ? { ...i, [field]: value } : i))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateSubtotal = () => {
|
||||||
|
return items.reduce((acc, item) => {
|
||||||
|
const qty = parseFloat(item.qty) || 0;
|
||||||
|
const price = parseFloat(item.price) || 0;
|
||||||
|
return acc + qty * price;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotal = () => {
|
||||||
|
const subtotal = calculateSubtotal();
|
||||||
|
const tax = parseFloat(taxAmount) || 0;
|
||||||
|
const discount = parseFloat(discountAmount) || 0;
|
||||||
|
return subtotal + tax - discount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validation
|
||||||
|
if (!proformaNumber || !customerName) {
|
||||||
|
toast.error("Error", "Please fill required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
proformaNumber,
|
||||||
|
customerName,
|
||||||
|
customerEmail,
|
||||||
|
customerPhone,
|
||||||
|
amount: calculateTotal(),
|
||||||
|
currency,
|
||||||
|
issueDate: issueDate.toISOString(),
|
||||||
|
dueDate: dueDate.toISOString(),
|
||||||
|
description,
|
||||||
|
notes,
|
||||||
|
taxAmount: parseFloat(taxAmount) || 0,
|
||||||
|
discountAmount: parseFloat(discountAmount) || 0,
|
||||||
|
items: items.map((item) => ({
|
||||||
|
description: item.description,
|
||||||
|
quantity: parseFloat(item.qty) || 0,
|
||||||
|
unitPrice: parseFloat(item.price) || 0,
|
||||||
|
total: (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
await api.proforma.update({
|
||||||
|
params: { id: id as string },
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
toast.success("Success", "Proforma updated successfully");
|
||||||
|
} else {
|
||||||
|
await api.proforma.create({ body: payload });
|
||||||
|
toast.success("Success", "Proforma created successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.back();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error("Error", error.message || "Failed to save proforma");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StandardHeader title={isEdit ? "Edit Proforma" : "Create Proforma"} showBack />
|
||||||
|
<View className="flex-1 justify-center items-center">
|
||||||
|
<ActivityIndicator color="#ea580c" size="large" />
|
||||||
|
</View>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies = ["USD", "EUR", "GBP", "CAD"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
|
||||||
|
<StandardHeader title={isEdit ? "Edit Proforma" : "Create Proforma"} showBack />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Proforma Details */}
|
||||||
|
<ShadowWrapper className="mb-4">
|
||||||
|
<View className="bg-card rounded-[12px] p-4">
|
||||||
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
|
||||||
|
Proforma Details
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-4">
|
||||||
|
<Field
|
||||||
|
label="Proforma Number"
|
||||||
|
value={proformaNumber}
|
||||||
|
onChangeText={setProformaNumber}
|
||||||
|
placeholder="Enter proforma number"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder="Brief description"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Notes"
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="Additional notes"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Customer Details */}
|
||||||
|
<ShadowWrapper className="mb-4">
|
||||||
|
<View className="bg-card rounded-[12px] p-4">
|
||||||
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
|
||||||
|
Customer Details
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-4">
|
||||||
|
<Field
|
||||||
|
label="Customer Name"
|
||||||
|
value={customerName}
|
||||||
|
onChangeText={setCustomerName}
|
||||||
|
placeholder="Enter customer name"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Customer Email"
|
||||||
|
value={customerEmail}
|
||||||
|
onChangeText={setCustomerEmail}
|
||||||
|
placeholder="Enter customer email"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Customer Phone"
|
||||||
|
value={customerPhone}
|
||||||
|
onChangeText={setCustomerPhone}
|
||||||
|
placeholder="Enter customer phone"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<ShadowWrapper className="mb-4">
|
||||||
|
<View className="bg-card rounded-[12px] p-4">
|
||||||
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
|
||||||
|
Dates
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-4">
|
||||||
|
<Pressable
|
||||||
|
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
|
||||||
|
onPress={() => setIssueModal(true)}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Calendar color="#64748b" size={16} />
|
||||||
|
<Text className="text-foreground font-medium">
|
||||||
|
Issue Date: {issueDate.toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ChevronDown color="#64748b" size={16} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
|
||||||
|
onPress={() => setDueModal(true)}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<CalendarSearch color="#64748b" size={16} />
|
||||||
|
<Text className="text-foreground font-medium">
|
||||||
|
Due Date: {dueDate.toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ChevronDown color="#64748b" size={16} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<ShadowWrapper className="mb-4">
|
||||||
|
<View className="bg-card rounded-[12px] p-4">
|
||||||
|
<View className="flex-row items-center justify-between mb-4">
|
||||||
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
|
||||||
|
Items
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
className="h-8 px-3 rounded-[6px] bg-primary"
|
||||||
|
onPress={addItem}
|
||||||
|
>
|
||||||
|
<Plus color="#ffffff" size={14} />
|
||||||
|
<Text className="ml-1 text-white text-xs font-bold">Add Item</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{items.map((item) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className="flex-row items-center gap-3 mb-3 p-3 bg-muted rounded-[6px]"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
flex={3}
|
||||||
|
label="Description"
|
||||||
|
value={item.description}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "description", v)}
|
||||||
|
placeholder="Item description"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
flex={1}
|
||||||
|
label="Qty"
|
||||||
|
value={item.qty}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "qty", v)}
|
||||||
|
placeholder="0"
|
||||||
|
numeric
|
||||||
|
center
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
flex={1.5}
|
||||||
|
label="Price"
|
||||||
|
value={item.price}
|
||||||
|
onChangeText={(v) => updateItem(item.id, "price", v)}
|
||||||
|
placeholder="0.00"
|
||||||
|
numeric
|
||||||
|
center
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
className="mt-4 p-2"
|
||||||
|
onPress={() => removeItem(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 color="#dc2626" size={16} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<ShadowWrapper className="mb-4">
|
||||||
|
<View className="bg-card rounded-[12px] p-4">
|
||||||
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
|
||||||
|
Totals
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="gap-3">
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<Text className="text-foreground font-medium">Subtotal</Text>
|
||||||
|
<Text className="text-foreground font-bold">
|
||||||
|
{currency} {calculateSubtotal().toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Tax Amount"
|
||||||
|
value={taxAmount}
|
||||||
|
onChangeText={setTaxAmount}
|
||||||
|
placeholder="0.00"
|
||||||
|
numeric
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Discount Amount"
|
||||||
|
value={discountAmount}
|
||||||
|
onChangeText={setDiscountAmount}
|
||||||
|
placeholder="0.00"
|
||||||
|
numeric
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between pt-2 border-t border-border">
|
||||||
|
<Text className="text-foreground font-bold text-lg">Total</Text>
|
||||||
|
<Text className="text-foreground font-bold text-lg">
|
||||||
|
{currency} {calculateTotal().toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
|
||||||
|
{/* Currency */}
|
||||||
|
<ShadowWrapper className="mb-4">
|
||||||
|
<View className="bg-card rounded-[12px] p-4">
|
||||||
|
<Text className="text-foreground font-bold text-sm uppercase tracking-widest mb-4">
|
||||||
|
Currency
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
className="flex-row items-center justify-between p-3 bg-muted rounded-[6px]"
|
||||||
|
onPress={() => setCurrencyModal(true)}
|
||||||
|
>
|
||||||
|
<Text className="text-foreground font-medium">{currency}</Text>
|
||||||
|
<ChevronDown color="#64748b" size={16} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ShadowWrapper>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Bottom Action */}
|
||||||
|
<View className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t border-border">
|
||||||
|
<Button
|
||||||
|
className="h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<ActivityIndicator color="#ffffff" size="small" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send color="#ffffff" size={16} strokeWidth={2.5} />
|
||||||
|
<Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
|
||||||
|
{isEdit ? "Update Proforma" : "Create Proforma"}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<PickerModal
|
||||||
|
visible={currencyModal}
|
||||||
|
title="Select Currency"
|
||||||
|
onClose={() => setCurrencyModal(false)}
|
||||||
|
>
|
||||||
|
{currencies.map((curr) => (
|
||||||
|
<SelectOption
|
||||||
|
key={curr}
|
||||||
|
label={curr}
|
||||||
|
value={curr}
|
||||||
|
selected={curr === currency}
|
||||||
|
onSelect={(v) => {
|
||||||
|
setCurrency(v);
|
||||||
|
setCurrencyModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
<CalendarGrid
|
||||||
|
open={issueModal}
|
||||||
|
current={issueDate.toISOString().substring(0,10)}
|
||||||
|
onDateSelect={(dateStr: string) => {
|
||||||
|
setIssueDate(new Date(dateStr));
|
||||||
|
setIssueModal(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setIssueModal(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
<CalendarGrid
|
||||||
|
open={dueModal}
|
||||||
|
current={dueDate.toISOString().substring(0,10)}
|
||||||
|
onDateSelect={(dateStr: string) => {
|
||||||
|
setDueDate(new Date(dateStr));
|
||||||
|
setDueModal(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setDueModal(false)}
|
||||||
|
/>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,18 +23,24 @@ import {
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Chrome,
|
Chrome,
|
||||||
|
Globe,
|
||||||
} from "@/lib/icons";
|
} from "@/lib/icons";
|
||||||
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useColorScheme } from "nativewind";
|
import { useColorScheme } from "nativewind";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
|
import { useLanguageStore, AppLanguage } from "@/lib/language-store";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
import { LanguageModal } from "@/components/LanguageModal";
|
||||||
|
|
||||||
export default function RegisterScreen() {
|
export default function RegisterScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const setAuth = useAuthStore((state) => state.setAuth);
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
const isDark = colorScheme === "dark";
|
const isDark = colorScheme === "dark";
|
||||||
|
const { language, setLanguage } = useLanguageStore();
|
||||||
|
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
|
|
@ -92,10 +98,19 @@ export default function RegisterScreen() {
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
contentContainerStyle={{ padding: 24, paddingBottom: 60 }}
|
contentContainerStyle={{ paddingHorizontal:16 , paddingBottom: 10 }}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
<View className="mb-10 mt-10">
|
<View className="flex-row justify-end mt-4">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setLanguageModalVisible(true)}
|
||||||
|
className="p-2 rounded-full bg-card border border-border"
|
||||||
|
>
|
||||||
|
<Globe color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="items-center mb-10">
|
||||||
<Text
|
<Text
|
||||||
variant="h2"
|
variant="h2"
|
||||||
className="mt-6 font-bold text-foreground text-center"
|
className="mt-6 font-bold text-foreground text-center"
|
||||||
|
|
@ -113,11 +128,11 @@ export default function RegisterScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
First Name
|
First Name
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
|
<View className="rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
<TextInput
|
<TextInput
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
placeholder="John"
|
placeholder="John"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.firstName}
|
value={form.firstName}
|
||||||
onChangeText={(v) => updateForm("firstName", v)}
|
onChangeText={(v) => updateForm("firstName", v)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -127,11 +142,11 @@ export default function RegisterScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Last Name
|
Last Name
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
|
<View className="rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
<TextInput
|
<TextInput
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
placeholder="Doe"
|
placeholder="Doe"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.lastName}
|
value={form.lastName}
|
||||||
onChangeText={(v) => updateForm("lastName", v)}
|
onChangeText={(v) => updateForm("lastName", v)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -143,12 +158,12 @@ export default function RegisterScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Email Address
|
Email Address
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground"
|
className="flex-1 ml-3 text-foreground"
|
||||||
placeholder="john@example.com"
|
placeholder="john@example.com"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChangeText={(v) => updateForm("email", v)}
|
onChangeText={(v) => updateForm("email", v)}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|
@ -161,7 +176,7 @@ export default function RegisterScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Phone Number
|
Phone Number
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<View className="flex-row items-center flex-1 ml-3">
|
<View className="flex-row items-center flex-1 ml-3">
|
||||||
<Text className="text-foreground text-sm font-medium">
|
<Text className="text-foreground text-sm font-medium">
|
||||||
|
|
@ -170,7 +185,7 @@ export default function RegisterScreen() {
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 text-foreground"
|
className="flex-1 text-foreground"
|
||||||
placeholder="911 234 567"
|
placeholder="911 234 567"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChangeText={(v) => updateForm("phone", v)}
|
onChangeText={(v) => updateForm("phone", v)}
|
||||||
keyboardType="phone-pad"
|
keyboardType="phone-pad"
|
||||||
|
|
@ -183,12 +198,12 @@ export default function RegisterScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Password
|
Password
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground"
|
className="flex-1 ml-3 text-foreground"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChangeText={(v) => updateForm("password", v)}
|
onChangeText={(v) => updateForm("password", v)}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
|
|
@ -197,7 +212,7 @@ export default function RegisterScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="h-14 bg-primary rounded-[10px ] shadow-lg shadow-primary/30 mt-4"
|
className="h-10 bg-primary rounded-[10px] shadow-lg shadow-primary/30 mt-4"
|
||||||
onPress={handleRegister}
|
onPress={handleRegister}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
|
@ -219,12 +234,18 @@ export default function RegisterScreen() {
|
||||||
onPress={() => nav.go("login")}
|
onPress={() => nav.go("login")}
|
||||||
>
|
>
|
||||||
<Text className="text-muted-foreground">
|
<Text className="text-muted-foreground">
|
||||||
Already have an account?{" "}
|
Already have an account? <Text className="text-primary">Sign In</Text>
|
||||||
<Text className="text-primary">Sign In</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
<LanguageModal
|
||||||
|
visible={languageModalVisible}
|
||||||
|
current={language}
|
||||||
|
onSelect={(lang) => setLanguage(lang)}
|
||||||
|
onClose={() => setLanguageModalVisible(false)}
|
||||||
|
/>
|
||||||
</ScreenWrapper>
|
</ScreenWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
162
app/sms-scan.tsx
162
app/sms-scan.tsx
|
|
@ -20,8 +20,11 @@ import { AppRoutes } from "@/lib/routes";
|
||||||
// Android only — iOS does not permit reading SMS
|
// Android only — iOS does not permit reading SMS
|
||||||
let SmsAndroid: any = null;
|
let SmsAndroid: any = null;
|
||||||
try {
|
try {
|
||||||
SmsAndroid = require("react-native-get-sms-android").default;
|
const smsModule = require("react-native-get-sms-android");
|
||||||
} catch (_) {}
|
SmsAndroid = smsModule.default || smsModule;
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[SMS] Module require failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
// Keywords to match Ethiopian banking SMS messages
|
// Keywords to match Ethiopian banking SMS messages
|
||||||
const BANK_KEYWORDS = ["CBE", "DashenBank", "Dashen", "127", "telebirr"];
|
const BANK_KEYWORDS = ["CBE", "DashenBank", "Dashen", "127", "telebirr"];
|
||||||
|
|
@ -34,6 +37,16 @@ interface SmsMessage {
|
||||||
date_sent: number;
|
date_sent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParsedPayment {
|
||||||
|
smsId: string;
|
||||||
|
bank: string;
|
||||||
|
amount: string;
|
||||||
|
ref: string;
|
||||||
|
date: number;
|
||||||
|
body: string;
|
||||||
|
sender: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SmsScanScreen() {
|
export default function SmsScanScreen() {
|
||||||
const nav = useSirouRouter<AppRoutes>();
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
const { colorScheme } = useColorScheme();
|
const { colorScheme } = useColorScheme();
|
||||||
|
|
@ -50,8 +63,8 @@ export default function SmsScanScreen() {
|
||||||
|
|
||||||
if (!SmsAndroid) {
|
if (!SmsAndroid) {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Package Missing",
|
"Native Module Error",
|
||||||
"Run: npm install react-native-get-sms-android",
|
"SMS scanning requires a Development Build. Expo Go does not support this package.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -76,8 +89,8 @@ export default function SmsScanScreen() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only look at messages from the past 5 minutes
|
// Only look at messages from the past 20 minutes
|
||||||
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
const fiveMinutesAgo = Date.now() - 20 * 60 * 1000;
|
||||||
|
|
||||||
const filter = {
|
const filter = {
|
||||||
box: "inbox",
|
box: "inbox",
|
||||||
|
|
@ -135,14 +148,73 @@ export default function SmsScanScreen() {
|
||||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBankLabel = (sms: SmsMessage) => {
|
const parseMessage = (sms: SmsMessage): ParsedPayment | null => {
|
||||||
const text = (sms.body + sms.address).toUpperCase();
|
const body = sms.body;
|
||||||
if (text.includes("CBE")) return { name: "CBE", color: "#16a34a" };
|
const addr = sms.address.toUpperCase();
|
||||||
if (text.includes("DASHEN"))
|
const text = (body + addr).toUpperCase();
|
||||||
return { name: "Dashen Bank", color: "#1d4ed8" };
|
|
||||||
if (text.includes("127") || text.includes("TELEBIRR"))
|
let bank = "Unknown";
|
||||||
return { name: "Telebirr", color: "#7c3aed" };
|
let amount = "";
|
||||||
return { name: "Bank", color: "#ea580c" };
|
let ref = "";
|
||||||
|
|
||||||
|
// CBE Patterns
|
||||||
|
if (text.includes("CBE") || addr === "CBE") {
|
||||||
|
bank = "CBE";
|
||||||
|
// Pattern: "ETB 1,234.56" or "ETB 1,234"
|
||||||
|
const amtMatch = body.match(/ETB\s*([\d,.]+)/i);
|
||||||
|
if (amtMatch) amount = amtMatch[1];
|
||||||
|
|
||||||
|
// Pattern: "Ref: 123456789"
|
||||||
|
const refMatch = body.match(/Ref:?\s*(\w+)/i);
|
||||||
|
if (refMatch) ref = refMatch[1];
|
||||||
|
}
|
||||||
|
// Telebirr Patterns
|
||||||
|
else if (text.includes("TELEBIRR") || addr === "TELEBIRR") {
|
||||||
|
bank = "Telebirr";
|
||||||
|
// Pattern: "Birr 1,234.56"
|
||||||
|
const amtMatch = body.match(/Birr\s*([\d,.]+)/i);
|
||||||
|
if (amtMatch) amount = amtMatch[1];
|
||||||
|
|
||||||
|
// Pattern: "Trans ID: 12345678"
|
||||||
|
const refMatch = body.match(/Trans ID:?\s*(\w+)/i);
|
||||||
|
if (refMatch) ref = refMatch[1];
|
||||||
|
}
|
||||||
|
// Dashen Patterns
|
||||||
|
else if (text.includes("DASHEN") || addr === "DASHEN") {
|
||||||
|
bank = "Dashen";
|
||||||
|
// Pattern: "ETB 1,234.56"
|
||||||
|
const amtMatch = body.match(/ETB\s*([\d,.]+)/i);
|
||||||
|
if (amtMatch) amount = amtMatch[1];
|
||||||
|
|
||||||
|
// Pattern: "Reference No: 12345678"
|
||||||
|
const refMatch = body.match(/(?:Ref(?:erence)?(?:\s*No)?):?\s*(\w+)/i);
|
||||||
|
if (refMatch) ref = refMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bank === "Unknown") return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
smsId: sms._id,
|
||||||
|
bank,
|
||||||
|
amount,
|
||||||
|
ref,
|
||||||
|
date: sms.date,
|
||||||
|
body: sms.body,
|
||||||
|
sender: sms.address,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBankColor = (bank: string) => {
|
||||||
|
switch (bank) {
|
||||||
|
case "CBE":
|
||||||
|
return "#16a34a";
|
||||||
|
case "Telebirr":
|
||||||
|
return "#7c3aed";
|
||||||
|
case "Dashen":
|
||||||
|
return "#1d4ed8";
|
||||||
|
default:
|
||||||
|
return "#ea580c";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -219,32 +291,70 @@ export default function SmsScanScreen() {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className="gap-3">
|
<View className="gap-4">
|
||||||
{messages.map((sms) => {
|
{messages.map((sms) => {
|
||||||
const bank = getBankLabel(sms);
|
const parsed = parseMessage(sms);
|
||||||
|
if (!parsed) return null;
|
||||||
|
|
||||||
|
const bankColor = getBankColor(parsed.bank);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={sms._id} className="rounded-[12px] bg-card p-4">
|
<Card
|
||||||
<View className="flex-row items-center justify-between mb-2">
|
key={sms._id}
|
||||||
|
className="rounded-[16px] bg-card p-4 border border-border/40"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center justify-between mb-3">
|
||||||
<View
|
<View
|
||||||
className="px-3 py-1 rounded-full"
|
className="px-3 py-1 rounded-full"
|
||||||
style={{ backgroundColor: bank.color + "20" }}
|
style={{ backgroundColor: bankColor + "15" }}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className="text-xs font-bold"
|
className="text-xs font-bold uppercase tracking-wider"
|
||||||
style={{ color: bank.color }}
|
style={{ color: bankColor }}
|
||||||
>
|
>
|
||||||
{bank.name}
|
{parsed.bank}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text variant="muted" className="text-xs">
|
<Text variant="muted" className="text-xs">
|
||||||
{formatTime(sms.date)}
|
{formatTime(sms.date)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-foreground text-sm leading-5">
|
|
||||||
{sms.body}
|
{/* Extracted Data */}
|
||||||
|
<View className="flex-row gap-4 mb-3">
|
||||||
|
{parsed.amount ? (
|
||||||
|
<View className="flex-1 bg-muted/30 p-2 rounded-[8px]">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] uppercase font-bold"
|
||||||
|
>
|
||||||
|
Amount
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="muted" className="text-xs mt-2">
|
<Text className="text-foreground font-bold text-sm">
|
||||||
From: {sms.address}
|
ETB {parsed.amount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{parsed.ref ? (
|
||||||
|
<View className="flex-1 bg-muted/30 p-2 rounded-[8px]">
|
||||||
|
<Text
|
||||||
|
variant="muted"
|
||||||
|
className="text-[10px] uppercase font-bold"
|
||||||
|
>
|
||||||
|
Reference
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-foreground font-bold text-sm"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{parsed.ref}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-foreground/70 text-xs leading-relaxed italic">
|
||||||
|
"{sms.body}"
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
86
app/terms.tsx
Normal file
86
app/terms.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { View, ScrollView } from "react-native";
|
||||||
|
import { ScreenWrapper } from "@/components/ScreenWrapper";
|
||||||
|
import { StandardHeader } from "@/components/StandardHeader";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export default function TermsScreen() {
|
||||||
|
return (
|
||||||
|
<ScreenWrapper className="bg-background">
|
||||||
|
<StandardHeader title="Terms of Service" showBack />
|
||||||
|
|
||||||
|
<ScrollView className="flex-1 px-5 pt-4" showsVerticalScrollIndicator={false}>
|
||||||
|
<Text variant="h4" className="text-foreground mb-4">
|
||||||
|
Terms of Service
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
Last updated: March 10, 2026
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
1. Acceptance of Terms
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
By accessing and using our mobile application, you accept and agree to be bound by the terms and provision of this agreement.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
2. Use of Service
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
Our service is provided "as is" and "as available" without warranties of any kind. You agree to use the service at your own risk.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
3. User Accounts
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
When you create an account with us, you must provide information that is accurate, complete, and current at all times.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
4. Prohibited Uses
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
You may not use our service:
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• For any unlawful purpose or to solicit others to perform unlawful acts
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-2">
|
||||||
|
• To violate any international, federal, provincial, or state regulations, rules, laws, or local ordinances
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
• To infringe upon or violate our intellectual property rights or the intellectual property rights of others
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
5. Termination
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We may terminate or suspend your account and bar access to the service immediately, without prior notice or liability, under our sole discretion.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
6. Limitation of Liability
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
In no event shall our company, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
7. Changes to Terms
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text variant="p" className="font-semibold text-foreground mb-2">
|
||||||
|
8. Contact Information
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mb-4">
|
||||||
|
If you have any questions about these Terms of Service, please contact us at terms@example.com.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</ScreenWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import { StandardHeader } from "@/components/StandardHeader";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast-store";
|
import { toast } from "@/lib/toast-store";
|
||||||
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { getPlaceholderColor } from "@/lib/colors";
|
||||||
|
|
||||||
const ROLES = ["VIEWER", "EMPLOYEE", "ACCOUNTANT", "CUSTOMER_SERVICE"];
|
const ROLES = ["VIEWER", "EMPLOYEE", "ACCOUNTANT", "CUSTOMER_SERVICE"];
|
||||||
|
|
||||||
|
|
@ -116,11 +117,11 @@ export default function CreateUserScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
First Name
|
First Name
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
|
<View className="rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
<TextInput
|
<TextInput
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
placeholder="First Name"
|
placeholder="First Name"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.firstName}
|
value={form.firstName}
|
||||||
onChangeText={(v) => updateForm("firstName", v)}
|
onChangeText={(v) => updateForm("firstName", v)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -130,11 +131,11 @@ export default function CreateUserScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Last Name
|
Last Name
|
||||||
</Text>
|
</Text>
|
||||||
<View className="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
|
<View className="rounded-xl px-4 border border-border h-12 justify-center">
|
||||||
<TextInput
|
<TextInput
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
placeholder="Last Name"
|
placeholder="Last Name"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.lastName}
|
value={form.lastName}
|
||||||
onChangeText={(v) => updateForm("lastName", v)}
|
onChangeText={(v) => updateForm("lastName", v)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -147,12 +148,12 @@ export default function CreateUserScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Email Address
|
Email Address
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground"
|
className="flex-1 ml-3 text-foreground"
|
||||||
placeholder="email@company.com"
|
placeholder="email@company.com"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChangeText={(v) => updateForm("email", v)}
|
onChangeText={(v) => updateForm("email", v)}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
|
@ -166,12 +167,12 @@ export default function CreateUserScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Phone Number
|
Phone Number
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground"
|
className="flex-1 ml-3 text-foreground"
|
||||||
placeholder="911 234 567"
|
placeholder="911 234 567"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.phone}
|
value={form.phone}
|
||||||
onChangeText={(v) => updateForm("phone", v)}
|
onChangeText={(v) => updateForm("phone", v)}
|
||||||
keyboardType="phone-pad"
|
keyboardType="phone-pad"
|
||||||
|
|
@ -186,10 +187,10 @@ export default function CreateUserScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setShowRolePicker(true)}
|
onPress={() => setShowRolePicker(true)}
|
||||||
className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12"
|
className="flex-row items-center rounded-xl px-4 border border-border h-12"
|
||||||
>
|
>
|
||||||
<ShieldCheck size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<ShieldCheck size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<Text className="flex-1 ml-3 text-foreground font-medium">
|
<Text className="flex-1 ml-3 text-foreground text-sm font-medium">
|
||||||
{form.role}
|
{form.role}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronDown size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<ChevronDown size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
|
|
@ -201,12 +202,12 @@ export default function CreateUserScreen() {
|
||||||
<Text variant="small" className="font-semibold mb-2 ml-1">
|
<Text variant="small" className="font-semibold mb-2 ml-1">
|
||||||
Initial Password
|
Initial Password
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12">
|
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
|
||||||
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
|
||||||
<TextInput
|
<TextInput
|
||||||
className="flex-1 ml-3 text-foreground"
|
className="flex-1 ml-3 text-foreground"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
|
placeholderTextColor={getPlaceholderColor(isDark)}
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChangeText={(v) => updateForm("password", v)}
|
onChangeText={(v) => updateForm("password", v)}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
|
|
|
||||||
BIN
assets/fonts/DMSans-Black.ttf
Normal file
BIN
assets/fonts/DMSans-Black.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Bold.ttf
Normal file
BIN
assets/fonts/DMSans-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-ExtraBold.ttf
Normal file
BIN
assets/fonts/DMSans-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-ExtraLight.ttf
Normal file
BIN
assets/fonts/DMSans-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Light.ttf
Normal file
BIN
assets/fonts/DMSans-Light.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Medium.ttf
Normal file
BIN
assets/fonts/DMSans-Medium.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Regular.ttf
Normal file
BIN
assets/fonts/DMSans-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-SemiBold.ttf
Normal file
BIN
assets/fonts/DMSans-SemiBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/DMSans-Thin.ttf
Normal file
BIN
assets/fonts/DMSans-Thin.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
78
components/EmptyState.tsx
Normal file
78
components/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import React from "react";
|
||||||
|
import { View, Pressable, useColorScheme } from "react-native";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
hint?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onActionPress?: () => void;
|
||||||
|
previewLines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
hint,
|
||||||
|
actionLabel,
|
||||||
|
onActionPress,
|
||||||
|
previewLines = 3,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
const scheme = useColorScheme();
|
||||||
|
const isDark = scheme === "dark";
|
||||||
|
|
||||||
|
const dashColor = isDark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.14)";
|
||||||
|
const lineFill = isDark ? "rgba(255,255,255,0.10)" : "rgba(0,0,0,0.08)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="w-full">
|
||||||
|
<View className="bg-card border border-border/20 rounded-2xl p-5">
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text variant="h3" className="text-foreground font-bold">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{!!description && (
|
||||||
|
<Text variant="muted" className="mt-1">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className="rounded-xl p-4"
|
||||||
|
style={{ borderWidth: 1, borderStyle: "dashed", borderColor: dashColor }}
|
||||||
|
>
|
||||||
|
<View className="gap-3">
|
||||||
|
{Array.from({ length: Math.max(1, previewLines) }).map((_, idx) => (
|
||||||
|
<View
|
||||||
|
key={idx}
|
||||||
|
className="rounded-md"
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
width: `${idx === 0 ? 90 : idx === 1 ? 72 : 80}%`,
|
||||||
|
backgroundColor: lineFill,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!!hint && (
|
||||||
|
<Text variant="muted" className="mt-4">
|
||||||
|
{hint}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!!actionLabel && !!onActionPress && (
|
||||||
|
<Pressable
|
||||||
|
onPress={onActionPress}
|
||||||
|
className="mt-5 bg-primary h-10 rounded-[6px] items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-sm font-bold">{actionLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/LanguageModal.tsx
Normal file
39
components/LanguageModal.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import React from "react";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
import { AppLanguage } from "@/lib/language-store";
|
||||||
|
|
||||||
|
interface LanguageModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
current: AppLanguage;
|
||||||
|
onSelect: (lang: AppLanguage) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageModal({
|
||||||
|
visible,
|
||||||
|
current,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: LanguageModalProps) {
|
||||||
|
const languages = [
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "am", label: "Amharic" },
|
||||||
|
] as { value: AppLanguage; label: string }[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PickerModal visible={visible} onClose={onClose} title="Language">
|
||||||
|
{languages.map((opt) => (
|
||||||
|
<SelectOption
|
||||||
|
key={opt.value}
|
||||||
|
label={opt.label}
|
||||||
|
value={opt.value}
|
||||||
|
selected={current === opt.value}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onSelect(v as AppLanguage);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
Platform,
|
Platform,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
|
useColorScheme,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -24,9 +25,15 @@ export function ScreenWrapper({
|
||||||
}: ScreenWrapperProps & { containerClassName?: string }) {
|
}: ScreenWrapperProps & { containerClassName?: string }) {
|
||||||
const Container = withSafeArea ? SafeAreaView : View;
|
const Container = withSafeArea ? SafeAreaView : View;
|
||||||
|
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={cn("flex-1 bg-background", containerClassName)} {...props}>
|
<View
|
||||||
<StatusBar barStyle="dark-content" />
|
className={cn("flex-1 bg-background pt-4", containerClassName)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<StatusBar barStyle={isDark ? "light-content" : "dark-content"} />
|
||||||
<Container className={cn("flex-1", className)}>{children}</Container>
|
<Container className={cn("flex-1", className)}>{children}</Container>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, ViewProps, Platform } from "react-native";
|
import { View, ViewProps, Platform } from "react-native";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useColorScheme } from "nativewind";
|
||||||
|
|
||||||
interface ShadowWrapperProps extends ViewProps {
|
interface ShadowWrapperProps extends ViewProps {
|
||||||
level?: "none" | "xs" | "sm" | "md" | "lg" | "xl";
|
level?: "none" | "xs" | "sm" | "md" | "lg" | "xl";
|
||||||
|
|
@ -14,17 +15,44 @@ export function ShadowWrapper({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: ShadowWrapperProps) {
|
}: ShadowWrapperProps) {
|
||||||
|
const { colorScheme } = useColorScheme();
|
||||||
|
const isDark = colorScheme === "dark";
|
||||||
|
|
||||||
const shadowClasses = {
|
const shadowClasses = {
|
||||||
none: "",
|
none: "",
|
||||||
xs: "shadow-sm shadow-slate-200/30",
|
xs: isDark ? "" : "shadow-sm shadow-slate-200/30",
|
||||||
sm: "shadow-sm shadow-slate-200/50",
|
sm: isDark ? "" : "shadow-sm shadow-slate-200/50",
|
||||||
md: "shadow-md shadow-slate-200/60",
|
md: isDark ? "" : "shadow-md shadow-slate-200/60",
|
||||||
lg: "shadow-xl shadow-slate-200/70",
|
lg: isDark ? "" : "shadow-xl shadow-slate-200/70",
|
||||||
xl: "shadow-2xl shadow-slate-300/40",
|
xl: isDark ? "" : "shadow-2xl shadow-slate-300/40",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const elevations = {
|
||||||
|
none: 0,
|
||||||
|
xs: 1,
|
||||||
|
sm: 2,
|
||||||
|
md: 4,
|
||||||
|
lg: 8,
|
||||||
|
xl: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Android elevation needs a background color to cast a shadow
|
||||||
|
const hasBgClass = className?.includes("bg-");
|
||||||
|
const androidBaseStyle =
|
||||||
|
Platform.OS === "android"
|
||||||
|
? {
|
||||||
|
elevation: isDark ? 0 : elevations[level],
|
||||||
|
backgroundColor: hasBgClass || isDark ? undefined : "white",
|
||||||
|
shadowColor: "#000",
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={cn(shadowClasses[level], className)} {...props}>
|
<View
|
||||||
|
className={cn(shadowClasses[level], className)}
|
||||||
|
style={[androidBaseStyle, props.style as any]}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
import { View, Image, Pressable, useColorScheme } from "react-native";
|
import { View, Image, Pressable, useColorScheme } from "react-native";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { ArrowLeft, Bell } from "@/lib/icons";
|
import { ArrowLeft, Bell, Settings, Info } from "@/lib/icons";
|
||||||
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
import { ShadowWrapper } from "@/components/ShadowWrapper";
|
||||||
import { useAuthStore } from "@/lib/auth-store";
|
import { useAuthStore } from "@/lib/auth-store";
|
||||||
import { router } from "expo-router";
|
import { useSirouRouter } from "@sirou/react-native";
|
||||||
|
import { AppRoutes } from "@/lib/routes";
|
||||||
|
|
||||||
interface StandardHeaderProps {
|
interface StandardHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
showBack?: boolean;
|
showBack?: boolean;
|
||||||
|
rightAction?: "notificationsSettings" | "companyInfo";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StandardHeader({ title, showBack }: StandardHeaderProps) {
|
export function StandardHeader({
|
||||||
|
title,
|
||||||
|
showBack,
|
||||||
|
rightAction,
|
||||||
|
}: StandardHeaderProps) {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
const isDark = colorScheme === "dark";
|
const isDark = colorScheme === "dark";
|
||||||
|
const nav = useSirouRouter<AppRoutes>();
|
||||||
|
|
||||||
// Fallback avatar if user has no profile picture
|
// Fallback avatar if user has no profile picture
|
||||||
const avatarUri =
|
const avatarUri =
|
||||||
|
|
@ -27,7 +34,7 @@ export function StandardHeader({ title, showBack }: StandardHeaderProps) {
|
||||||
<View className="flex-1 flex-row items-center gap-3">
|
<View className="flex-1 flex-row items-center gap-3">
|
||||||
{showBack && (
|
{showBack && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => nav.back()}
|
||||||
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
>
|
>
|
||||||
<ArrowLeft color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
|
<ArrowLeft color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
|
||||||
|
|
@ -38,7 +45,7 @@ export function StandardHeader({ title, showBack }: StandardHeaderProps) {
|
||||||
<View className="flex-row items-center gap-3 ml-1">
|
<View className="flex-row items-center gap-3 ml-1">
|
||||||
<View>
|
<View>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.push("/profile")}
|
onPress={() => nav.go("profile")}
|
||||||
className="h-[40px] w-[40px] rounded-full overflow-hidden"
|
className="h-[40px] w-[40px] rounded-full overflow-hidden"
|
||||||
>
|
>
|
||||||
<Image source={{ uri: avatarUri }} className="h-full w-full" />
|
<Image source={{ uri: avatarUri }} className="h-full w-full" />
|
||||||
|
|
@ -57,7 +64,7 @@ export function StandardHeader({ title, showBack }: StandardHeaderProps) {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex-1 items-center mr-10">
|
<View className="flex-1 items-center ">
|
||||||
<Text variant="h4" className="text-foreground font-semibold">
|
<Text variant="h4" className="text-foreground font-semibold">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -66,18 +73,39 @@ export function StandardHeader({ title, showBack }: StandardHeaderProps) {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{!title && (
|
{!title && (
|
||||||
<ShadowWrapper level="xs">
|
<Pressable
|
||||||
<Pressable className="rounded-full p-2.5 border border-border">
|
className="rounded-full p-2.5 border border-border"
|
||||||
|
onPress={() => nav.go("notifications/index")}
|
||||||
|
>
|
||||||
<Bell
|
<Bell
|
||||||
color={isDark ? "#f1f5f9" : "#0f172a"}
|
color={isDark ? "#f1f5f9" : "#0f172a"}
|
||||||
size={20}
|
size={20}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ShadowWrapper>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{title && <View className="w-0" />}
|
{title && (
|
||||||
|
<View className="w-10 items-end">
|
||||||
|
{rightAction === "notificationsSettings" ? (
|
||||||
|
<Pressable
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
onPress={() => nav.go("notifications/settings")}
|
||||||
|
>
|
||||||
|
<Settings color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
|
||||||
|
</Pressable>
|
||||||
|
) : rightAction === "companyInfo" ? (
|
||||||
|
<Pressable
|
||||||
|
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
|
||||||
|
onPress={() => nav.go("company-details")}
|
||||||
|
>
|
||||||
|
<Info color={isDark ? "#f1f5f9" : "#0f172a"} size={18} />
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<View className="w-0" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
components/ThemeModal.tsx
Normal file
41
components/ThemeModal.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from "react";
|
||||||
|
import { PickerModal, SelectOption } from "@/components/PickerModal";
|
||||||
|
|
||||||
|
type AppTheme = (typeof THEME_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
|
interface ThemeModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
current: AppTheme;
|
||||||
|
onSelect: (theme: AppTheme) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_OPTIONS = [
|
||||||
|
{ value: "light", label: "Light" },
|
||||||
|
{ value: "dark", label: "Dark" },
|
||||||
|
{ value: "system", label: "System Default" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ThemeModal({
|
||||||
|
visible,
|
||||||
|
current,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: ThemeModalProps) {
|
||||||
|
return (
|
||||||
|
<PickerModal visible={visible} onClose={onClose} title="Appearance">
|
||||||
|
{THEME_OPTIONS.map((opt) => (
|
||||||
|
<SelectOption
|
||||||
|
key={opt.value}
|
||||||
|
label={opt.label}
|
||||||
|
value={opt.value}
|
||||||
|
selected={current === opt.value}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onSelect(v as "light" | "dark" | "system");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</PickerModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,22 +30,22 @@ const TOAST_VARIANTS: Record<
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
success: {
|
success: {
|
||||||
bg: "#f0fdf4",
|
bg: "rgba(34, 197, 94, 0.05)",
|
||||||
border: "#22c55e",
|
border: "#22c55e",
|
||||||
icon: <CheckCircle2 size={24} color="#22c55e" />,
|
icon: <CheckCircle2 size={24} color="#22c55e" />,
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
bg: "#f0f9ff",
|
bg: "rgba(14, 165, 233, 0.05)",
|
||||||
border: "#0ea5e9",
|
border: "#0ea5e9",
|
||||||
icon: <Lightbulb size={24} color="#0ea5e9" />,
|
icon: <Lightbulb size={24} color="#0ea5e9" />,
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
bg: "#fffbeb",
|
bg: "rgba(245, 158, 11, 0.05)",
|
||||||
border: "#f59e0b",
|
border: "#f59e0b",
|
||||||
icon: <AlertTriangle size={24} color="#f59e0b" />,
|
icon: <AlertTriangle size={24} color="#f59e0b" />,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
bg: "#fef2f2",
|
bg: "rgba(239, 68, 68, 0.05)",
|
||||||
border: "#ef4444",
|
border: "#ef4444",
|
||||||
icon: <AlertCircle size={24} color="#ef4444" />,
|
icon: <AlertCircle size={24} color="#ef4444" />,
|
||||||
},
|
},
|
||||||
|
|
@ -115,12 +115,11 @@ export function Toast() {
|
||||||
styles.container,
|
styles.container,
|
||||||
{
|
{
|
||||||
top: insets.top + 10,
|
top: insets.top + 10,
|
||||||
backgroundColor: variant.bg,
|
|
||||||
borderColor: variant.border,
|
borderColor: variant.border,
|
||||||
},
|
},
|
||||||
animatedStyle,
|
animatedStyle,
|
||||||
]}
|
]}
|
||||||
className="border-2 rounded-2xl shadow-xl flex-row items-center p-4 pr-10"
|
className="border-2 rounded-2xl shadow-xl bg-background dark:bg-background dark:shadow-none flex-row items-center p-4 pr-10"
|
||||||
>
|
>
|
||||||
<View className="mr-4">{variant.icon}</View>
|
<View className="mr-4">{variant.icon}</View>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,102 @@
|
||||||
import { TextClassContext } from '@/components/ui/text';
|
import { TextClassContext } from "@/components/ui/text";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from "@/lib/utils";
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { Platform, Pressable } from 'react-native';
|
import { Platform, Pressable } from "react-native";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
cn(
|
cn(
|
||||||
'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
|
"group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none",
|
||||||
Platform.select({
|
Platform.select({
|
||||||
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
web: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: cn(
|
default: cn(
|
||||||
'bg-primary active:bg-primary/90 shadow-sm shadow-black/5',
|
"bg-primary active:bg-primary/90 shadow-sm dark:shadow-none shadow-black/5",
|
||||||
Platform.select({ web: 'hover:bg-primary/90' })
|
Platform.select({ web: "hover:bg-primary/90" }),
|
||||||
),
|
),
|
||||||
destructive: cn(
|
destructive: cn(
|
||||||
'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm shadow-black/5',
|
"bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm dark:shadow-none shadow-black/5",
|
||||||
Platform.select({
|
Platform.select({
|
||||||
web: 'hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
|
web: "hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
outline: cn(
|
outline: cn(
|
||||||
'border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm shadow-black/5',
|
"border-border bg-background active:bg-accent dark:bg-input/30 dark:border-input dark:active:bg-input/50 border shadow-sm dark:shadow-none shadow-black/5",
|
||||||
Platform.select({
|
Platform.select({
|
||||||
web: 'hover:bg-accent dark:hover:bg-input/50',
|
web: "hover:bg-accent dark:hover:bg-input/50",
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
secondary: cn(
|
secondary: cn(
|
||||||
'bg-secondary active:bg-secondary/80 shadow-sm shadow-black/5',
|
"bg-secondary active:bg-secondary/80 shadow-sm dark:shadow-none shadow-black/5",
|
||||||
Platform.select({ web: 'hover:bg-secondary/80' })
|
Platform.select({ web: "hover:bg-secondary/80" }),
|
||||||
),
|
),
|
||||||
ghost: cn(
|
ghost: cn(
|
||||||
'active:bg-accent dark:active:bg-accent/50',
|
"active:bg-accent dark:active:bg-accent/50",
|
||||||
Platform.select({ web: 'hover:bg-accent dark:hover:bg-accent/50' })
|
Platform.select({ web: "hover:bg-accent dark:hover:bg-accent/50" }),
|
||||||
),
|
),
|
||||||
link: '',
|
link: "",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),
|
default: cn(
|
||||||
sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })),
|
"h-10 px-4 py-2 sm:h-9",
|
||||||
lg: cn('h-11 rounded-md px-6 sm:h-10', Platform.select({ web: 'has-[>svg]:px-4' })),
|
Platform.select({ web: "has-[>svg]:px-3" }),
|
||||||
icon: 'h-10 w-10 sm:h-9 sm:w-9',
|
),
|
||||||
|
sm: cn(
|
||||||
|
"h-9 gap-1.5 rounded-md px-3 sm:h-8",
|
||||||
|
Platform.select({ web: "has-[>svg]:px-2.5" }),
|
||||||
|
),
|
||||||
|
lg: cn(
|
||||||
|
"h-11 rounded-md px-6 sm:h-10",
|
||||||
|
Platform.select({ web: "has-[>svg]:px-4" }),
|
||||||
|
),
|
||||||
|
icon: "h-10 w-10 sm:h-9 sm:w-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const buttonTextVariants = cva(
|
const buttonTextVariants = cva(
|
||||||
cn(
|
cn(
|
||||||
'text-foreground text-sm font-medium',
|
"text-foreground text-sm font-medium",
|
||||||
Platform.select({ web: 'pointer-events-none transition-colors' })
|
Platform.select({ web: "pointer-events-none transition-colors" }),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'text-primary-foreground',
|
default: "text-primary-foreground",
|
||||||
destructive: 'text-white',
|
destructive: "text-white",
|
||||||
outline: cn(
|
outline: cn(
|
||||||
'group-active:text-accent-foreground',
|
"group-active:text-accent-foreground",
|
||||||
Platform.select({ web: 'group-hover:text-accent-foreground' })
|
Platform.select({ web: "group-hover:text-accent-foreground" }),
|
||||||
),
|
),
|
||||||
secondary: 'text-secondary-foreground',
|
secondary: "text-secondary-foreground",
|
||||||
ghost: 'group-active:text-accent-foreground',
|
ghost: "group-active:text-accent-foreground",
|
||||||
link: cn(
|
link: cn(
|
||||||
'text-primary group-active:underline',
|
"text-primary group-active:underline",
|
||||||
Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })
|
Platform.select({
|
||||||
|
web: "underline-offset-4 hover:underline group-hover:underline",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: '',
|
default: "",
|
||||||
sm: '',
|
sm: "",
|
||||||
lg: '',
|
lg: "",
|
||||||
icon: '',
|
icon: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
type ButtonProps = React.ComponentProps<typeof Pressable> &
|
type ButtonProps = React.ComponentProps<typeof Pressable> &
|
||||||
|
|
@ -96,7 +107,11 @@ function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||||
return (
|
return (
|
||||||
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
|
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
|
||||||
<Pressable
|
<Pressable
|
||||||
className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}
|
className={cn(
|
||||||
|
props.disabled && "opacity-50",
|
||||||
|
buttonVariants({ variant, size }),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
role="button"
|
role="button"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,10 @@ import { ShadowWrapper } from "../ShadowWrapper";
|
||||||
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
function Card({ className, ...props }: ViewProps & React.RefAttributes<View>) {
|
||||||
return (
|
return (
|
||||||
<TextClassContext.Provider value="text-card-foreground">
|
<TextClassContext.Provider value="text-card-foreground">
|
||||||
<ShadowWrapper>
|
|
||||||
<View
|
<View
|
||||||
className={cn("bg-card flex flex-col gap-4 rounded-xl ", className)}
|
className={cn("bg-card flex flex-col border border-border gap-4 rounded-xl ", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</ShadowWrapper>
|
|
||||||
</TextClassContext.Provider>
|
</TextClassContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ import * as Slot from "@rn-primitives/slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Platform, Text as RNText, type Role } from "react-native";
|
import { Platform, Text as RNText, type Role } from "react-native";
|
||||||
|
import { useColorScheme } from "react-native";
|
||||||
|
import { getMutedColor } from "@/lib/colors";
|
||||||
|
|
||||||
const textVariants = cva(
|
const textVariants = cva(
|
||||||
cn(
|
cn(
|
||||||
"text-foreground text-base",
|
"text-foreground text-base font-sans",
|
||||||
Platform.select({
|
Platform.select({
|
||||||
web: "select-text",
|
web: "select-text",
|
||||||
}),
|
}),
|
||||||
|
|
@ -82,11 +84,14 @@ function Text({
|
||||||
}) {
|
}) {
|
||||||
const textClass = React.useContext(TextClassContext);
|
const textClass = React.useContext(TextClassContext);
|
||||||
const Component = asChild ? Slot.Text : RNText;
|
const Component = asChild ? Slot.Text : RNText;
|
||||||
|
const isDark = useColorScheme() === 'dark';
|
||||||
|
const mutedStyle = variant === "muted" ? { color: getMutedColor(isDark) } : undefined;
|
||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
className={cn(textVariants({ variant }), textClass, className)}
|
className={cn(textVariants({ variant }), textClass, className)}
|
||||||
role={variant ? ROLE[variant] : undefined}
|
role={variant ? ROLE[variant] : undefined}
|
||||||
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
|
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
|
||||||
|
style={mutedStyle}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
86
global.css
86
global.css
|
|
@ -4,57 +4,47 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 255,255,255;
|
||||||
--foreground: 0 0% 3.9%;
|
--foreground: 37,22,21;
|
||||||
--card: 0 0% 100%;
|
--card: 255,255,255;
|
||||||
--card-foreground: 0 0% 3.9%;
|
--card-foreground: 37,22,21;
|
||||||
--popover: 0 0% 100%;
|
--popover: 255,249,244;
|
||||||
--popover-foreground: 0 0% 3.9%;
|
--popover-foreground: 37,22,21;
|
||||||
--primary: 24 90% 48%;
|
--primary: 228, 98, 18;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 255,249,244;
|
||||||
--secondary: 0 0% 96.1%;
|
--secondary: 255,226,216;
|
||||||
--secondary-foreground: 0 0% 9%;
|
--secondary-foreground: 66,37,32;
|
||||||
--muted: 0 0% 96.1%;
|
--muted: 255,234,227;
|
||||||
--muted-foreground: 0 0% 45.1%;
|
--muted-foreground: 118,93,88;
|
||||||
--accent: 0 0% 96.1%;
|
--accent: 255,222,207;
|
||||||
--accent-foreground: 0 0% 9%;
|
--accent-foreground: 66,37,32;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 239,67,94;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 255,249,244;
|
||||||
--border: 0 0% 89.8%;
|
--border: 237,213,209;
|
||||||
--input: 0 0% 89.8%;
|
--input: 244,206,198;
|
||||||
--ring: 0 0% 63%;
|
--ring: 233,87,82;
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--chart-1: 12 76% 61%;
|
|
||||||
--chart-2: 173 58% 39%;
|
|
||||||
--chart-3: 197 37% 24%;
|
|
||||||
--chart-4: 43 74% 66%;
|
|
||||||
--chart-5: 27 87% 67%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark:root {
|
.dark:root {
|
||||||
--background: 0 0% 3.9%;
|
--background: 22,22,22;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 255,241,238;
|
||||||
--card: 0 0% 3.9%;
|
--card: 31,31,31;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 255,241,238;
|
||||||
--popover: 0 0% 3.9%;
|
--popover: 31, 31, 31;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 255,241,238;
|
||||||
--primary: 0 0% 98%;
|
--primary: 228, 98, 18;
|
||||||
--primary-foreground: 0 0% 9%;
|
--primary-foreground: 0,0,0;
|
||||||
--secondary: 0 0% 14.9%;
|
--secondary: 15, 9, 11;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 255,241,238;
|
||||||
--muted: 0 0% 14.9%;
|
--muted: 9, 5, 6;
|
||||||
--muted-foreground: 0 0% 63.9%;
|
--muted-foreground: 176,153,151;
|
||||||
--accent: 0 0% 14.9%;
|
--accent: 228, 125, 251;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 255,249,244;
|
||||||
--destructive: 0 70.9% 59.4%;
|
--destructive: 255,40,90;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 255,249,244;
|
||||||
--border: 0 0% 14.9%;
|
--border: 95, 95, 95;
|
||||||
--input: 0 0% 14.9%;
|
--input: 16,9,10;
|
||||||
--ring: 300 0% 45%;
|
--ring: 151,170,81;
|
||||||
--chart-1: 220 70% 50%;
|
|
||||||
--chart-2: 160 60% 45%;
|
|
||||||
--chart-3: 30 80% 55%;
|
|
||||||
--chart-4: 280 65% 60%;
|
|
||||||
--chart-5: 340 75% 55%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
lib/api.ts
30
lib/api.ts
|
|
@ -6,7 +6,7 @@ import {
|
||||||
import { authMiddleware, refreshMiddleware } from "./api-middlewares";
|
import { authMiddleware, refreshMiddleware } from "./api-middlewares";
|
||||||
|
|
||||||
// Trailing slash is essential for relative path resolution
|
// Trailing slash is essential for relative path resolution
|
||||||
export const BASE_URL = "https://api.yaltopiaticket.com/api/v1/";
|
export const BASE_URL = "https://api.yaltopiaticket.com/";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central API client using simple-api
|
* Central API client using simple-api
|
||||||
|
|
@ -19,6 +19,14 @@ export const api = createApi({
|
||||||
refreshMiddleware,
|
refreshMiddleware,
|
||||||
],
|
],
|
||||||
services: {
|
services: {
|
||||||
|
notifications: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
getAll: { method: "GET", path: "notifications" },
|
||||||
|
settings: { method: "GET", path: "notifications/settings" },
|
||||||
|
update: { method: "PUT", path: "notifications/settings" },
|
||||||
|
},
|
||||||
|
},
|
||||||
news: {
|
news: {
|
||||||
middleware: [authMiddleware],
|
middleware: [authMiddleware],
|
||||||
endpoints: {
|
endpoints: {
|
||||||
|
|
@ -30,12 +38,11 @@ export const api = createApi({
|
||||||
middleware: [authMiddleware],
|
middleware: [authMiddleware],
|
||||||
endpoints: {
|
endpoints: {
|
||||||
login: { method: "POST", path: "auth/login" },
|
login: { method: "POST", path: "auth/login" },
|
||||||
register: { method: "POST", path: "auth/register-owner" },
|
register: { method: "POST", path: "auth/login-or-register-owner" },
|
||||||
refresh: { method: "POST", path: "auth/refresh" },
|
refresh: { method: "POST", path: "auth/refresh" },
|
||||||
logout: { method: "POST", path: "auth/logout" },
|
logout: { method: "POST", path: "auth/logout" },
|
||||||
profile: { method: "GET", path: "auth/profile" },
|
profile: { method: "GET", path: "auth/profile" },
|
||||||
google: { method: "GET", path: "auth/google" },
|
googleMobile: { method: "POST", path: "auth/google/mobile" },
|
||||||
callback: { method: "GET", path: "auth/google/callback" },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invoices: {
|
invoices: {
|
||||||
|
|
@ -73,12 +80,26 @@ export const api = createApi({
|
||||||
getAll: { method: "GET", path: "payments" },
|
getAll: { method: "GET", path: "payments" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
paymentRequests: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
create: { method: "POST", path: "payment-requests" },
|
||||||
|
},
|
||||||
|
},
|
||||||
proforma: {
|
proforma: {
|
||||||
middleware: [authMiddleware],
|
middleware: [authMiddleware],
|
||||||
endpoints: {
|
endpoints: {
|
||||||
getAll: { method: "GET", path: "proforma" },
|
getAll: { method: "GET", path: "proforma" },
|
||||||
getById: { method: "GET", path: "proforma/:id" },
|
getById: { method: "GET", path: "proforma/:id" },
|
||||||
create: { method: "POST", path: "proforma" },
|
create: { method: "POST", path: "proforma" },
|
||||||
|
update: { method: "PUT", path: "proforma/:id" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rbac: {
|
||||||
|
middleware: [authMiddleware],
|
||||||
|
endpoints: {
|
||||||
|
roles: { method: "GET", path: "rbac/roles" },
|
||||||
|
permissions: { method: "GET", path: "rbac/permissions" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -95,3 +116,4 @@ export const authApi = api.auth;
|
||||||
export const newsApi = api.news;
|
export const newsApi = api.news;
|
||||||
export const invoicesApi = api.invoices;
|
export const invoicesApi = api.invoices;
|
||||||
export const proformaApi = api.proforma;
|
export const proformaApi = api.proforma;
|
||||||
|
export const rbacApi = api.rbac;
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,9 @@ interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
refreshToken: string | null;
|
refreshToken: string | null;
|
||||||
|
permissions: string[];
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
setAuth: (user: User, token: string, refreshToken?: string) => void;
|
setAuth: (user: User, token: string, refreshToken?: string, permissions?: string[]) => void;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
updateUser: (user: Partial<User>) => void;
|
updateUser: (user: Partial<User>) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -37,17 +38,20 @@ export const useAuthStore = create<AuthState>()(
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
|
permissions: [],
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
setAuth: (user, token, refreshToken = undefined) => {
|
setAuth: (user, token, refreshToken = undefined, permissions = []) => {
|
||||||
console.log("[AuthStore] Setting auth state:", {
|
console.log("[AuthStore] Setting auth state:", {
|
||||||
hasUser: !!user,
|
hasUser: !!user,
|
||||||
hasToken: !!token,
|
hasToken: !!token,
|
||||||
hasRefreshToken: !!refreshToken,
|
hasRefreshToken: !!refreshToken,
|
||||||
|
permissions,
|
||||||
});
|
});
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
token,
|
token,
|
||||||
refreshToken: refreshToken ?? null,
|
refreshToken: refreshToken ?? null,
|
||||||
|
permissions,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -70,6 +74,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
|
permissions: [],
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
19
lib/colors.ts
Normal file
19
lib/colors.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consistent colors for placeholders and muted text throughout the app.
|
||||||
|
* Dark: rgba(255,255,255,0.6), Light: rgba(0,0,0,0.6)
|
||||||
|
*/
|
||||||
|
export const getPlaceholderColor = (isDark: boolean) => isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)';
|
||||||
|
export const getMutedColor = (isDark: boolean) => isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get consistent colors based on current theme.
|
||||||
|
*/
|
||||||
|
export const useAppColors = () => {
|
||||||
|
const isDark = useColorScheme() === 'dark';
|
||||||
|
return {
|
||||||
|
placeholder: getPlaceholderColor(isDark),
|
||||||
|
muted: getMutedColor(isDark),
|
||||||
|
};
|
||||||
|
};
|
||||||
23
lib/language-store.ts
Normal file
23
lib/language-store.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
export type AppLanguage = "en" | "am";
|
||||||
|
|
||||||
|
type LanguageState = {
|
||||||
|
language: AppLanguage;
|
||||||
|
setLanguage: (lang: AppLanguage) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLanguageStore = create<LanguageState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
language: "en",
|
||||||
|
setLanguage: (language) => set({ language }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "app-language",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
54
lib/permissions.ts
Normal file
54
lib/permissions.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
|
||||||
|
export const PERMISSION_MAP = {
|
||||||
|
// Invoices
|
||||||
|
"invoices:read": "invoices:read",
|
||||||
|
"invoices:create": "invoices:create",
|
||||||
|
|
||||||
|
// Proforma
|
||||||
|
"proforma:read": "proforma:read",
|
||||||
|
"proforma:create": "proforma:create",
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
"payments:read": "payments:read",
|
||||||
|
"payments:create": "payments:create",
|
||||||
|
|
||||||
|
// Users
|
||||||
|
"users:read": "users:read",
|
||||||
|
"users:create": "users:create",
|
||||||
|
|
||||||
|
// News
|
||||||
|
"news:read": "news:read",
|
||||||
|
|
||||||
|
// Company
|
||||||
|
"company:read": "company:read",
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
"notifications:read": "notifications:read",
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
"profile:update": "profile:update",
|
||||||
|
|
||||||
|
// Scan
|
||||||
|
"scan:create": "scan:create",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to check if user has a specific permission.
|
||||||
|
*/
|
||||||
|
export function hasPermission(userPermissions: string[], permission: string): boolean {
|
||||||
|
return userPermissions.includes(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to check if user has any of the permissions.
|
||||||
|
*/
|
||||||
|
export function hasAnyPermission(userPermissions: string[], permissions: string[]): boolean {
|
||||||
|
return permissions.some(perm => userPermissions.includes(perm));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to check if user has all permissions.
|
||||||
|
*/
|
||||||
|
export function hasAllPermissions(userPermissions: string[], permissions: string[]): boolean {
|
||||||
|
return permissions.every(perm => userPermissions.includes(perm));
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,21 @@ export const routes = defineRoutes({
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
help: {
|
||||||
|
path: "/help",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Help & Support" },
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
path: "/privacy",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Privacy Policy" },
|
||||||
|
},
|
||||||
|
terms: {
|
||||||
|
path: "/terms",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Terms of Service" },
|
||||||
|
},
|
||||||
// Stacks
|
// Stacks
|
||||||
"proforma/[id]": {
|
"proforma/[id]": {
|
||||||
path: "/proforma/:id",
|
path: "/proforma/:id",
|
||||||
|
|
@ -55,12 +70,22 @@ export const routes = defineRoutes({
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
"proforma/edit": {
|
||||||
|
path: "/proforma/edit",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
"payments/[id]": {
|
"payments/[id]": {
|
||||||
path: "/payments/:id",
|
path: "/payments/:id",
|
||||||
params: { id: "string" },
|
params: { id: "string" },
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
"payment-requests/create": {
|
||||||
|
path: "/payment-requests/create",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
"invoices/[id]": {
|
"invoices/[id]": {
|
||||||
path: "/invoices/:id",
|
path: "/invoices/:id",
|
||||||
params: { id: "string" },
|
params: { id: "string" },
|
||||||
|
|
@ -112,6 +137,11 @@ export const routes = defineRoutes({
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
meta: { requiresAuth: true, title: "Company" },
|
meta: { requiresAuth: true, title: "Company" },
|
||||||
},
|
},
|
||||||
|
"company-details": {
|
||||||
|
path: "/company/details",
|
||||||
|
guards: ["auth"],
|
||||||
|
meta: { requiresAuth: true, title: "Company details" },
|
||||||
|
},
|
||||||
"user/create": {
|
"user/create": {
|
||||||
path: "/user/create",
|
path: "/user/create",
|
||||||
guards: ["auth"],
|
guards: ["auth"],
|
||||||
|
|
|
||||||
89
lib/theme.ts
89
lib/theme.ts
|
|
@ -2,56 +2,48 @@ import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
|
||||||
|
|
||||||
export const THEME = {
|
export const THEME = {
|
||||||
light: {
|
light: {
|
||||||
background: "hsl(0 0% 100%)",
|
background: "rgba(255,243,238,1)",
|
||||||
foreground: "hsl(0 0% 3.9%)",
|
foreground: "rgba(37,22,21,1)",
|
||||||
card: "hsl(0 0% 100%)",
|
card: "rgba(255,249,244,1)",
|
||||||
cardForeground: "hsl(0 0% 3.9%)",
|
cardForeground: "rgba(37,22,21,1)",
|
||||||
popover: "hsl(0 0% 100%)",
|
popover: "rgba(255,249,244,1)",
|
||||||
popoverForeground: "hsl(0 0% 3.9%)",
|
popoverForeground: "rgba(37,22,21,1)",
|
||||||
primary: "hsl(24 90% 48%)",
|
primary: "rgba(233,87,82,1)",
|
||||||
primaryForeground: "hsl(0 0% 100%)",
|
primaryForeground: "rgba(255,249,244,1)",
|
||||||
secondary: "hsl(0 0% 96.1%)",
|
secondary: "rgba(255,226,216,1)",
|
||||||
secondaryForeground: "hsl(0 0% 9%)",
|
secondaryForeground: "rgba(66,37,32,1)",
|
||||||
muted: "hsl(0 0% 96.1%)",
|
muted: "rgba(255,234,227,1)",
|
||||||
mutedForeground: "hsl(0 0% 45.1%)",
|
mutedForeground: "rgba(118,93,88,1)",
|
||||||
accent: "hsl(0 0% 96.1%)",
|
accent: "rgba(255,222,207,1)",
|
||||||
accentForeground: "hsl(0 0% 9%)",
|
accentForeground: "rgba(66,37,32,1)",
|
||||||
destructive: "hsl(0 84.2% 60.2%)",
|
destructive: "rgba(239,67,94,1)",
|
||||||
border: "hsl(0 0% 89.8%)",
|
destructiveForeground: "rgba(255,249,244,1)",
|
||||||
input: "hsl(0 0% 89.8%)",
|
border: "rgba(237,213,209,1)",
|
||||||
ring: "hsl(0 0% 63%)",
|
input: "rgba(244,206,198,1)",
|
||||||
|
ring: "rgba(233,87,82,1)",
|
||||||
radius: "0.625rem",
|
radius: "0.625rem",
|
||||||
chart1: "hsl(12 76% 61%)",
|
|
||||||
chart2: "hsl(173 58% 39%)",
|
|
||||||
chart3: "hsl(197 37% 24%)",
|
|
||||||
chart4: "hsl(43 74% 66%)",
|
|
||||||
chart5: "hsl(27 87% 67%)",
|
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
background: "hsl(0 0% 3.9%)",
|
background: "rgba(25,21,21,1)",
|
||||||
foreground: "hsl(0 0% 98%)",
|
foreground: "rgba(255,241,238,1)",
|
||||||
card: "hsl(0 0% 3.9%)",
|
card: "rgba(35,30,29,1)",
|
||||||
cardForeground: "hsl(0 0% 98%)",
|
cardForeground: "rgba(255,241,238,1)",
|
||||||
popover: "hsl(0 0% 3.9%)",
|
popover: "rgba(35,30,29,1)",
|
||||||
popoverForeground: "hsl(0 0% 98%)",
|
popoverForeground: "rgba(255,241,238,1)",
|
||||||
primary: "hsl(0 0% 98%)",
|
primary: "rgba(233,87,82,1)",
|
||||||
primaryForeground: "hsl(0 0% 9%)",
|
primaryForeground: "rgba(0,0,0,1)",
|
||||||
secondary: "hsl(0 0% 14.9%)",
|
secondary: "rgba(16,9,10,1)",
|
||||||
secondaryForeground: "hsl(0 0% 98%)",
|
secondaryForeground: "rgba(255,241,238,1)",
|
||||||
muted: "hsl(0 0% 14.9%)",
|
muted: "rgba(9,5,5,1)",
|
||||||
mutedForeground: "hsl(0 0% 63.9%)",
|
mutedForeground: "rgba(176,153,151,1)",
|
||||||
accent: "hsl(0 0% 14.9%)",
|
accent: "rgba(197,156,221,1)",
|
||||||
accentForeground: "hsl(0 0% 98%)",
|
accentForeground: "rgba(255,249,244,1)",
|
||||||
destructive: "hsl(0 70.9% 59.4%)",
|
destructive: "rgba(255,40,90,1)",
|
||||||
border: "hsl(0 0% 14.9%)",
|
destructiveForeground: "rgba(255,249,244,1)",
|
||||||
input: "hsl(0 0% 14.9%)",
|
border: "rgba(105,93,92,1)",
|
||||||
ring: "hsl(300 0% 45%)",
|
input: "rgba(16,9,10,1)",
|
||||||
|
ring: "rgba(151,170,81,1)",
|
||||||
radius: "0.625rem",
|
radius: "0.625rem",
|
||||||
chart1: "hsl(220 70% 50%)",
|
|
||||||
chart2: "hsl(160 60% 45%)",
|
|
||||||
chart3: "hsl(30 80% 55%)",
|
|
||||||
chart4: "hsl(280 65% 60%)",
|
|
||||||
chart5: "hsl(340 75% 55%)",
|
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -106,6 +98,9 @@ export async function loadTheme(): Promise<AppTheme> {
|
||||||
export function useRestoreTheme() {
|
export function useRestoreTheme() {
|
||||||
const { setColorScheme } = useColorScheme();
|
const { setColorScheme } = useColorScheme();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTheme().then((t) => setColorScheme(t));
|
// Only set it once on load
|
||||||
|
loadTheme().then((t) => {
|
||||||
|
if (t) setColorScheme(t);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2054
package-lock.json
generated
2054
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -4,8 +4,8 @@
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"postinstall": "patch-package"
|
"postinstall": "patch-package"
|
||||||
},
|
},
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"@expo/metro-runtime": "~4.0.1",
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/datetimepicker": "8.2.0",
|
"@react-native-community/datetimepicker": "8.2.0",
|
||||||
|
"@react-native-google-signin/google-signin": "^16.1.2",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@rn-primitives/portal": "^1.1.0",
|
"@rn-primitives/portal": "^1.1.0",
|
||||||
"@rn-primitives/slot": "^1.1.0",
|
"@rn-primitives/slot": "^1.1.0",
|
||||||
|
|
@ -53,7 +54,8 @@
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"@react-native-community/cli": "latest"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,81 @@
|
||||||
const { hairlineWidth } = require('nativewind/theme');
|
const { hairlineWidth } = require("nativewind/theme");
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: 'class',
|
darkMode: "class",
|
||||||
content: ['./App.tsx', './index.ts', './components/**/*.{js,jsx,ts,tsx}', './app/**/*.{js,jsx,ts,tsx}'],
|
content: [
|
||||||
presets: [require('nativewind/preset')],
|
"./App.tsx",
|
||||||
|
"./index.ts",
|
||||||
|
"./components/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./app/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
presets: [require("nativewind/preset")],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['DMSans-Regular', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: "rgba(var(--border), <alpha-value>)",
|
||||||
input: 'hsl(var(--input))',
|
input: "rgba(var(--input), <alpha-value>)",
|
||||||
ring: 'hsl(var(--ring))',
|
ring: "rgba(var(--ring), <alpha-value>)",
|
||||||
background: 'hsl(var(--background))',
|
background: "rgba(var(--background), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: "rgba(var(--foreground), <alpha-value>)",
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: "rgba(var(--primary), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--primary-foreground))',
|
foreground: "rgba(var(--primary-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: "rgba(var(--secondary), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
foreground: "rgba(var(--secondary-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
DEFAULT: "rgba(var(--destructive), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--destructive-foreground))',
|
foreground: "rgba(var(--destructive-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: "rgba(var(--muted), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--muted-foreground))',
|
foreground: "rgba(var(--muted-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: "rgba(var(--accent), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--accent-foreground))',
|
foreground: "rgba(var(--accent-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: "rgba(var(--popover), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--popover-foreground))',
|
foreground: "rgba(var(--popover-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: "rgba(var(--card), <alpha-value>)",
|
||||||
foreground: 'hsl(var(--card-foreground))',
|
foreground: "rgba(var(--card-foreground), <alpha-value>)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: "var(--radius)",
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: "calc(var(--radius) - 2px)",
|
||||||
sm: 'calc(var(--radius) - 4px)',
|
sm: "calc(var(--radius) - 4px)",
|
||||||
},
|
},
|
||||||
borderWidth: {
|
borderWidth: {
|
||||||
hairline: hairlineWidth(),
|
hairline: hairlineWidth(),
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'accordion-down': {
|
"accordion-down": {
|
||||||
from: { height: '0' },
|
from: { height: "0" },
|
||||||
to: { height: 'var(--radix-accordion-content-height)' },
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
},
|
},
|
||||||
'accordion-up': {
|
"accordion-up": {
|
||||||
from: { height: 'var(--radix-accordion-content-height)' },
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
to: { height: '0' },
|
to: { height: "0" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
future: {
|
future: {
|
||||||
hoverOnlyWhenSupported: true,
|
hoverOnlyWhenSupported: true,
|
||||||
},
|
},
|
||||||
plugins: [require('tailwindcss-animate')],
|
plugins: [require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user