ui cleanup

This commit is contained in:
elnatansamuel25 2026-03-11 22:48:53 +03:00
parent 7162fb87e8
commit be2bde41a2
60 changed files with 5793 additions and 809 deletions

View File

View File

@ -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"

View File

@ -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>
), ),
}} }}
/> />

View File

@ -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>

View File

@ -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>

View File

@ -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>
); );

View File

@ -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">

View File

@ -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

View File

@ -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
View 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>
);
}

View File

@ -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}
/> />

View File

@ -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
View 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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View 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
View 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>
);
}

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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
View 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>
);
}

View File

@ -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>
); );
} }

View File

@ -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
View 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>
);
}

View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

78
components/EmptyState.tsx Normal file
View 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>
);
}

View 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>
);
}

View File

@ -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>
); );

View File

@ -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>
); );

View File

@ -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
View 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>
);
}

View File

@ -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>

View File

@ -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}
/> />

View File

@ -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>
); );
} }

View File

@ -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}
/> />
); );

View File

@ -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%;
} }
} }

View File

@ -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;

View File

@ -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
View 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
View 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
View 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));
}

View File

@ -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"],

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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
} }

View File

@ -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")],
}; };