Compare commits

..

No commits in common. "be2bde41a28bb3478e1571652fef31688da2c5b5" and "837e3f4646f471b7d25e96b76f635c4c94764b61" have entirely different histories.

75 changed files with 4472 additions and 16125 deletions

3
.gitignore vendored
View File

@ -39,6 +39,3 @@ yarn-error.*
# generated native folders # generated native folders
/ios /ios
/android /android
*.apk
*.aab

View File

@ -1,54 +1 @@
{ {"expo":{"name":"Yaltopia Tickets App","slug":"yaltopia-tickets-app","version":"1.0.0","orientation":"portrait","icon":"./assets/icon.png","userInterfaceStyle":"light","newArchEnabled":true,"splash":{"image":"./assets/splash-icon.png","resizeMode":"contain","backgroundColor":"#ffffff"},"ios":{"supportsTablet":true},"android":{"adaptiveIcon":{"foregroundImage":"./assets/adaptive-icon.png","backgroundColor":"#ffffff"},"edgeToEdgeEnabled":true,"predictiveBackGestureEnabled":false},"web":{"favicon":"./assets/favicon.png","bundler":"metro"},"scheme":"yaltopia-tickets"}}
"expo": {
"name": "Yaltopia Tickets App",
"slug": "yaltopia-tickets-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.yaltopia.ticketapp"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"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": {
"favicon": "./assets/favicon.png",
"bundler": "metro"
},
"scheme": "yaltopia-tickets",
"extra": {
"eas": {
"projectId": "9b79b7de-5639-41ef-a72c-8c226354cd2e"
}
}
}
}

View File

@ -1,127 +1,62 @@
import { Tabs, router } from "expo-router"; import { Tabs } from 'expo-router';
import { Home, ScanLine, FileText, Wallet, Newspaper, Scan } from "@/lib/icons"; import { Home, ScanLine, FileText, Wallet, User } from '@/lib/icons';
import { useColorScheme } from "nativewind";
import { Platform, View, Pressable } from "react-native";
const ACTIVE_TINT = "rgba(228, 98, 18, 1)"; const NAV_BG = '#2d2d2d';
const INACTIVE_TINT = "#94a3b8"; const ACTIVE_TINT = '#ea580c';
const INACTIVE_TINT = '#a1a1aa';
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={{
headerShown: false, headerStyle: { backgroundColor: NAV_BG },
tabBarShowLabel: true, headerTintColor: '#ffffff',
headerTitleStyle: { fontWeight: '600', fontSize: 18 },
tabBarStyle: { backgroundColor: NAV_BG, paddingTop: 8 },
tabBarActiveTintColor: ACTIVE_TINT, tabBarActiveTintColor: ACTIVE_TINT,
tabBarInactiveTintColor: INACTIVE_TINT, tabBarInactiveTintColor: INACTIVE_TINT,
tabBarButton: ({ ref, ...navProps }) => ( tabBarLabelStyle: { fontSize: 11 },
<Pressable {...navProps} android_ripple={null} /> tabBarShowLabel: true,
),
tabBarLabelStyle: {
fontSize: 9,
fontWeight: "700",
marginBottom: Platform.OS === "ios" ? 0 : 4,
textTransform: "uppercase",
letterSpacing: 0.5,
},
tabBarStyle: {
backgroundColor: NAV_BG,
borderTopWidth: 0,
elevation: isDark ? 0 : 6,
shadowColor: "#000",
shadowOffset: { width: 0, height: -10 },
shadowOpacity: isDark ? 0 : 0.1,
shadowRadius: 20,
height: Platform.OS === "ios" ? 75 : 75,
paddingBottom: Platform.OS === "ios" ? 30 : 10,
paddingTop: 10,
marginHorizontal: 20,
position: "absolute",
bottom: 25,
left: 20,
right: 20,
borderRadius: 32,
},
}} }}
> >
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
tabBarLabel: "Home", title: 'Home',
tabBarIcon: ({ color, focused }) => ( tabBarLabel: 'Home',
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}> tabBarIcon: ({ color, size }) => <Home color={color} size={size ?? 22} strokeWidth={2} />,
<Home color={color} size={18} strokeWidth={focused ? 2.5 : 2} />
</View>
),
}}
/>
<Tabs.Screen
name="payments"
options={{
tabBarLabel: "Payments",
tabBarIcon: ({ color, focused }) => (
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}>
<Wallet color={color} size={18} strokeWidth={focused ? 2.5 : 2} />
</View>
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="scan" name="scan"
options={{ options={{
tabBarLabel: "SCAN", title: 'Scan Invoice',
tabBarLabelStyle: { tabBarLabel: 'Scan',
fontSize: 9, tabBarIcon: ({ color, size }) => <ScanLine color={color} size={size ?? 22} strokeWidth={2} />,
fontWeight: "700",
color: INACTIVE_TINT,
},
tabBarIcon: ({ focused }) => (
<View className="-mt-12">
<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} />
</View>
</View>
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="proforma" name="proforma"
options={{ options={{
tabBarLabel: "Proforma", title: 'Proforma',
tabBarIcon: ({ color, focused }) => ( tabBarLabel: 'Proforma',
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}> tabBarIcon: ({ color, size }) => <FileText color={color} size={size ?? 22} strokeWidth={2} />,
<FileText
color={color}
size={18}
strokeWidth={focused ? 2.5 : 2}
/>
</View>
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="news" name="payments"
options={{ options={{
tabBarLabel: "News", title: 'Payments',
tabBarIcon: ({ color, focused }) => ( tabBarLabel: 'Payments',
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}> tabBarIcon: ({ color, size }) => <Wallet color={color} size={size ?? 22} strokeWidth={2} />,
<Newspaper }}
color={color}
size={18}
strokeWidth={focused ? 2.5 : 2}
/> />
</View> <Tabs.Screen
), name="profile"
options={{
title: 'Profile',
tabBarLabel: 'Profile',
tabBarIcon: ({ color, size }) => <User color={color} size={size ?? 22} strokeWidth={2} />,
}} }}
/> />
</Tabs> </Tabs>

View File

@ -1,354 +1,147 @@
import React, { useState } from "react"; import { View, ScrollView, Pressable } from 'react-native';
import { import { Text } from '@/components/ui/text';
View, import { Button } from '@/components/ui/button';
ScrollView, import { Card, CardContent } from '@/components/ui/card';
Pressable, import { EARNINGS_SUMMARY, MOCK_INVOICES, MOCK_USER } from '@/lib/mock-data';
ActivityIndicator, import { router } from 'expo-router';
useColorScheme, import { Camera, Send, ChevronRight, Wallet, DollarSign, Clock } from '@/lib/icons';
} from "react-native";
import { api } from "@/lib/api"; const PRIMARY = '#ea580c';
import { Text } from "@/components/ui/text"; const statusColor: Record<string, string> = {
import { EmptyState } from "@/components/EmptyState"; Waiting: 'bg-amber-500/20 text-amber-700',
import { Card, CardContent } from "@/components/ui/card"; Paid: 'bg-emerald-500/20 text-emerald-700',
import { useSirouRouter } from "@sirou/react-native"; Draft: 'bg-gray-200 text-gray-700',
import { AppRoutes } from "@/lib/routes"; Unpaid: 'bg-red-500/20 text-red-700',
import { };
Plus,
History as HistoryIcon,
Briefcase,
ChevronRight,
Clock,
DollarSign,
FileText,
ScanLine,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { useAuthStore } from "@/lib/auth-store";
export default function HomeScreen() { export default function HomeScreen() {
const [activeFilter, setActiveFilter] = useState("All");
const [stats, setStats] = useState({
total: 0,
paid: 0,
pending: 0,
overdue: 0,
totalRevenue: 0,
});
const [invoices, setInvoices] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const nav = useSirouRouter<AppRoutes>();
React.useEffect(() => {
fetchStats();
}, []);
React.useEffect(() => {
fetchInvoices();
}, [activeFilter]);
const fetchStats = async () => {
const { isAuthenticated } = useAuthStore.getState();
if (!isAuthenticated) return;
try {
const data = await api.invoices.stats();
setStats(data);
} catch (e) {
console.error("[HomeScreen] Failed to fetch stats:", e);
}
};
const fetchInvoices = async () => {
const { isAuthenticated } = useAuthStore.getState();
if (!isAuthenticated) return;
setLoading(true);
try {
const statusParam =
activeFilter === "All" ? undefined : activeFilter.toUpperCase();
const response = await api.invoices.getAll({
query: {
limit: 5,
status: statusParam,
},
});
setInvoices(response.data || []);
} catch (e) {
console.error("[HomeScreen] Failed to fetch invoices:", e);
} finally {
setLoading(false);
}
};
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
return ( return (
<ScreenWrapper className="bg-background">
<ScrollView <ScrollView
className="flex-1 bg-[#f5f5f5]"
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: 10,
paddingBottom: 150,
}}
> >
<StandardHeader /> <View className="mb-5">
{/* Balance Card Section */} <Text className="text-2xl font-bold text-gray-900">Hi {MOCK_USER.name},</Text>
<View className="px-[16px] pt-6"> <Text className="text-muted-foreground mt-1 text-base">Take a look at your last activity.</Text>
<View className="mb-4">
<Card className="overflow-hidden rounded-[10px] border-0 bg-primary">
<View className="p-4 relative">
<View
className="absolute -top-10 -right-10 w-48 h-48 bg-white/10 rounded-full"
style={{ transform: [{ scale: 1.5 }] }}
/>
<Text className="text-white/60 text-[14px] font-semibold">
Available Balance
</Text>
<View className="mt-2 flex-row items-baseline">
<Text className="text-white text-2xl font-medium">$</Text>
<Text className="ml-1 text-4xl font-bold text-white">
{stats.total.toLocaleString()}
</Text>
</View> </View>
<View className="mt-4 flex-row gap-4"> <Card className="mb-5 overflow-hidden rounded-2xl border-0 shadow-sm">
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl"> <View className="bg-primary/10 px-5 py-5">
<View className="flex-row items-center gap-2"> <Text className="text-muted-foreground text-sm">Earnings balance</Text>
<View className="p-1.5 bg-white/20 rounded-lg"> <Text className="mt-1 text-3xl font-bold text-gray-900">${EARNINGS_SUMMARY.balance.toLocaleString()}</Text>
<Clock color="white" size={12} strokeWidth={2.5} />
</View> </View>
<Text className="text-white text-[12px] font-semibold"> <View className="flex-row border-t border-border">
Pending
</Text>
</View>
<Text className="text-white font-bold text-xl mt-2">
${stats.pending.toLocaleString()}
</Text>
</View>
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl">
<View className="flex-row items-center gap-2">
<View className="p-1.5 bg-white/20 rounded-lg">
<DollarSign color="white" size={12} strokeWidth={2.5} />
</View>
<Text className="text-white text-[12px] font-semibold">
Income
</Text>
</View>
<Text className="text-white font-bold text-xl mt-2">
${stats.totalRevenue.toLocaleString()}
</Text>
</View>
</View>
</View>
</Card>
</View>
{/* Circular Quick Actions Section */}
<View className="mb-4 flex-row justify-around items-center px-2">
<QuickAction
icon={
<Briefcase
color={isDark ? "#f1f5f9" : "#0f172a"}
size={20}
strokeWidth={1.5}
/>
}
label="Company"
onPress={() => nav.go("company")}
/>
<QuickAction
icon={
<ScanLine
color={isDark ? "#f1f5f9" : "#0f172a"}
size={20}
strokeWidth={1.5}
/>
}
label="Scan SMS"
onPress={() => nav.go("sms-scan")}
/>
<QuickAction
icon={
<Plus
color={isDark ? "#f1f5f9" : "#0f172a"}
size={20}
strokeWidth={1.5}
/>
}
label="Create Proforma"
onPress={() => nav.go("proforma/create")}
/>
<QuickAction
icon={
<HistoryIcon
color={isDark ? "#f1f5f9" : "#0f172a"}
size={20}
strokeWidth={1.5}
/>
}
label="History"
onPress={() => nav.go("history")}
/>
</View>
{/* Recent Activity Header */}
<View className="mb-4 flex-row justify-between items-center">
<Text variant="h4" className="text-foreground tracking-tight">
Recent Activity
</Text>
<Pressable <Pressable
onPress={() => nav.go("history")} className="flex-1 flex-row items-center gap-3 px-5 py-4"
className="px-4 py-2 rounded-full" onPress={() => router.push('/(tabs)/payments')}
> >
<Text className="text-primary font-bold text-xs">View all</Text> <View className="rounded-xl bg-primary/15 p-2">
<Clock color={PRIMARY} size={20} strokeWidth={2} />
</View>
<View>
<Text className="text-muted-foreground text-xs">Waiting for pay</Text>
<Text className="font-semibold text-gray-900">${EARNINGS_SUMMARY.waitingAmount.toLocaleString()}</Text>
<Text className="text-muted-foreground text-xs">{EARNINGS_SUMMARY.waitingCount} Waiting invoice</Text>
</View>
</Pressable>
<View className="w-px bg-border" />
<Pressable className="flex-1 flex-row items-center gap-3 px-5 py-4">
<View className="rounded-xl bg-emerald-500/15 p-2">
<DollarSign color="#059669" size={20} strokeWidth={2} />
</View>
<View>
<Text className="text-muted-foreground text-xs">Paid this month</Text>
<Text className="font-semibold text-gray-900">${EARNINGS_SUMMARY.paidThisMonth.toLocaleString()}</Text>
<Text className="text-muted-foreground text-xs">{EARNINGS_SUMMARY.paidCount} Paid invoice</Text>
</View>
</Pressable> </Pressable>
</View> </View>
</Card>
{/* Filters */} <View className="mb-5 flex-row gap-3">
<View className="mb-6"> <Button className="min-h-12 flex-1 rounded-xl bg-primary" onPress={() => router.push('/(tabs)/scan')}>
<ScrollView <Camera color="#ffffff" size={20} strokeWidth={2} />
horizontal <Text className="ml-2 text-primary-foreground font-medium">Scan invoice</Text>
showsHorizontalScrollIndicator={false} </Button>
contentContainerStyle={{ gap: 8 }} <Button
variant="outline"
className="min-h-12 flex-1 rounded-xl border-border"
onPress={() => router.push('/(tabs)/proforma')}
> >
{["All", "Draft", "Pending", "Paid", "Overdue", "Cancelled"].map( <Send color={PRIMARY} size={20} strokeWidth={2} />
(filter) => ( <Text className="ml-2 font-medium text-gray-700">Send proforma</Text>
</Button>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="-mx-1 mb-4">
<View className="flex-row gap-2 px-1">
{['All', 'Draft', 'Waiting', 'Paid', 'Unpaid'].map((filter) => (
<Pressable <Pressable
key={filter} key={filter}
onPress={() => setActiveFilter(filter)} className={`rounded-full px-4 py-2.5 ${filter === 'Waiting' ? 'bg-primary' : 'bg-white'} border border-border`}
className={`rounded-[4px] px-4 py-1.5 ${
activeFilter === filter
? "bg-primary"
: "bg-card border border-border"
}`}
> >
<Text <Text
className={`text-xs font-bold ${ className={
activeFilter === filter filter === 'Waiting' ? 'text-primary-foreground text-sm font-medium' : 'text-muted-foreground text-sm'
? "text-white" }
: "text-muted-foreground"
}`}
> >
{filter} {filter}
</Text> </Text>
</Pressable> </Pressable>
), ))}
)} </View>
</ScrollView> </ScrollView>
</View>
{/* Transactions List */} <View className="mb-2 flex-row items-center gap-2">
<View className="gap-2"> <View className="h-px flex-1 bg-border" />
{loading ? ( <Text className="text-muted-foreground text-xs font-medium">Today</Text>
<ActivityIndicator color="#ea580c" className="py-20" /> <View className="h-px flex-1 bg-border" />
) : invoices.length > 0 ? (
invoices.map((inv) => (
<Pressable
key={inv.id}
onPress={() => nav.go("invoices/[id]", { id: inv.id })}
>
<ShadowWrapper level="xs">
<Card className="overflow-hidden rounded-[6px] bg-card">
<CardContent className="flex-row items-center py-3 px-2">
<View className="bg-secondary/40 rounded-[6px] p-2 mr-2 border border-border/10">
<FileText
className="text-muted-foreground"
size={22}
strokeWidth={2}
/>
</View> </View>
<View className="flex-1 mt-[-20px]"> {MOCK_INVOICES.filter((i) => i.status === 'Waiting').map((inv) => (
<Text <Pressable key={inv.id} onPress={() => router.push(`/invoices/${inv.id}`)}>
variant="p" <Card className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
className="text-foreground font-semibold" <CardContent className="flex-row items-center justify-between py-4 pl-4 pr-3">
> <View className="flex-1">
{inv.customerName} <Text className="font-semibold text-gray-900">{inv.recipient}</Text>
</Text> <Text className="text-muted-foreground mt-0.5 text-sm">Invoice #{inv.invoiceNumber} · Due {inv.dueDate}</Text>
<Text
variant="muted"
className="mt-1 text-[11px] font-medium opacity-70"
>
{new Date(inv.issueDate).toLocaleDateString()} ·
Proforma
</Text>
</View> </View>
<View className="items-end mt-[-20px]"> <View className="items-end gap-1">
<Text <Text className="font-semibold text-gray-900">${inv.amount.toLocaleString()}</Text>
variant="p" <View className={`rounded-full px-2.5 py-1 ${statusColor[inv.status]}`}>
className="text-foreground font-semibold" <Text className="text-xs font-medium">{inv.status}</Text>
>
${Number(inv.amount).toLocaleString()}
</Text>
<View
className={`mt-1 rounded-[5px] px-3 py-1 border border-border ${
inv.status === "PAID"
? "bg-emerald-500/30 text-emerald-600"
: inv.status === "PENDING"
? "bg-amber-500/30 text-amber-600"
: inv.status === "DRAFT"
? "bg-secondary text-muted-foreground"
: "bg-red-500/30 text-red-600"
}`}
>
<Text className="text-[9px] font-semibold uppercase tracking-widest">
{inv.status}
</Text>
</View> </View>
</View> </View>
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
</CardContent> </CardContent>
</Card> </Card>
</ShadowWrapper>
</Pressable> </Pressable>
)) ))}
) : (
<View className="py-10"> <View className="mb-2 mt-6 flex-row items-center gap-2">
<EmptyState <View className="h-px flex-1 bg-border" />
title="No transactions yet" <Text className="text-muted-foreground text-xs font-medium">Yesterday</Text>
description="Your recent activity will show up here once you create and send invoices." <View className="h-px flex-1 bg-border" />
hint="Create a proforma invoice to get started."
actionLabel="Create Proforma"
onActionPress={() => nav.go("proforma/create")}
previewLines={3}
/>
</View> </View>
)} {MOCK_INVOICES.filter((i) => i.status === 'Paid').map((inv) => (
<Pressable key={inv.id} onPress={() => router.push(`/invoices/${inv.id}`)}>
<Card className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
<CardContent className="flex-row items-center justify-between py-4 pl-4 pr-3">
<View className="flex-1">
<Text className="font-semibold text-gray-900">{inv.recipient}</Text>
<Text className="text-muted-foreground mt-0.5 text-sm">Invoice #{inv.invoiceNumber} · {inv.dueDate}</Text>
</View>
<View className="items-end gap-1">
<Text className="font-semibold text-gray-900">${inv.amount.toLocaleString()}</Text>
<View className={`rounded-full px-2.5 py-1 ${statusColor[inv.status]}`}>
<Text className="text-xs font-medium">{inv.status}</Text>
</View> </View>
</View> </View>
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
</CardContent>
</Card>
</Pressable>
))}
</ScrollView> </ScrollView>
</ScreenWrapper>
);
}
function QuickAction({
icon,
label,
onPress,
}: {
icon: React.ReactNode;
label: string;
onPress?: () => void;
}) {
return (
<View className="pt-2 items-center w-[75px]">
<Pressable
onPress={onPress}
className="h-12 w-12 rounded-full bg-card border border-border/20 items-center justify-center flex-shrink-0"
>
{icon}
</Pressable>
<Text
variant="p"
className="flex-1 text-foreground text-[12px] font-bold tracking-tight text-center leading-4"
>
{label}
</Text>
</View>
); );
} }

View File

@ -1,313 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import {
View,
ScrollView,
Pressable,
ActivityIndicator,
FlatList,
Dimensions,
RefreshControl,
} from "react-native";
import { Text } from "@/components/ui/text";
import { Card } from "@/components/ui/card";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Newspaper, ChevronRight, Clock } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { EmptyState } from "@/components/EmptyState";
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
import { api, newsApi } from "@/lib/api";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { useAuthStore } from "@/lib/auth-store";
const { width } = Dimensions.get("window");
const LATEST_CARD_WIDTH = width * 0.8;
interface NewsItem {
id: string;
title: string;
content: string;
category: "ANNOUNCEMENT" | "UPDATE" | "MAINTENANCE" | "NEWS";
priority: "LOW" | "MEDIUM" | "HIGH";
publishedAt: string;
viewCount: number;
}
export default function NewsScreen() {
const nav = useSirouRouter<AppRoutes>();
const permissions = useAuthStore((s: { permissions: string[] }) => s.permissions);
// Safe accessor to handle initialization race conditions
const getNewsApi = () => {
if (newsApi) return newsApi;
return api.news;
};
// Latest News State
const [latestNews, setLatestNews] = useState<NewsItem[]>([]);
const [loadingLatest, setLoadingLatest] = useState(true);
// All News State
const [allNews, setAllNews] = useState<NewsItem[]>([]);
const [loadingAll, setLoadingAll] = useState(true);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// Check permissions (none for viewing news)
const fetchLatest = async () => {
try {
setLoadingLatest(true);
const service = getNewsApi();
if (!service) throw new Error("News service unavailable");
const data = await service.getLatest({ query: { limit: 5 } });
setLatestNews(data || []);
} catch (err) {
console.error("[News] Latest fetch error:", err);
} finally {
setLoadingLatest(false);
}
};
const fetchAll = async (pageNum: number, isRefresh = false) => {
try {
if (!isRefresh) {
pageNum === 1 ? setLoadingAll(true) : setLoadingMore(true);
}
const service = getNewsApi();
if (!service) throw new Error("News service unavailable");
const response = await service.getAll({
query: { page: pageNum, limit: 10, isPublished: true },
});
const newData = response.data || [];
if (isRefresh) {
setAllNews(newData);
} else {
setAllNews((prev) => (pageNum === 1 ? newData : [...prev, ...newData]));
}
setHasMore(response?.meta?.hasNextPage ?? false);
setPage(pageNum);
} catch (err) {
console.error("[News] All fetch error:", err);
} finally {
setLoadingAll(false);
setLoadingMore(false);
setRefreshing(false);
}
};
const onRefresh = () => {
setRefreshing(true);
fetchLatest();
fetchAll(1, true);
};
useEffect(() => {
fetchLatest();
fetchAll(1);
}, []);
const loadMore = () => {
if (hasMore && !loadingMore && !loadingAll) {
fetchAll(page + 1);
}
};
const getCategoryColor = (category: string) => {
switch (category) {
case "ANNOUNCEMENT":
return "bg-amber-500";
case "UPDATE":
return "bg-blue-500";
case "MAINTENANCE":
return "bg-red-500";
default:
return "bg-emerald-500";
}
};
const LatestItem = ({ item }: { item: NewsItem }) => (
<Pressable className="mr-4" key={item.id}>
<ShadowWrapper level="md">
<Card
className="overflow-hidden rounded-[20px] bg-card border-border/50"
style={{ width: LATEST_CARD_WIDTH, height: 160 }}
>
<View className="p-5 flex-1 justify-between">
<View>
<View className="flex-row items-center gap-2 mb-2">
<View
className={`px-2 py-0.5 rounded-full ${getCategoryColor(item.category)}`}
>
<Text className="text-[8px] font-black text-white uppercase tracking-tighter">
{item.category}
</Text>
</View>
<Text variant="muted" className="text-[10px] font-bold">
{new Date(item.publishedAt).toLocaleDateString()}
</Text>
</View>
<Text
className="text-foreground font-black text-lg leading-tight"
numberOfLines={2}
>
{item.title}
</Text>
</View>
<View className="flex-row justify-between items-center">
<Text variant="muted" className="text-xs font-medium opacity-60">
Tap to read more
</Text>
<View className="bg-primary/10 p-1.5 rounded-full">
<ChevronRight color="#ea580c" size={14} strokeWidth={3} />
</View>
</View>
</View>
</Card>
</ShadowWrapper>
</Pressable>
);
const NewsItem = ({ item }: { item: NewsItem }) => (
<Pressable className="mb-4" key={item.id}>
<ShadowWrapper level="xs">
<Card className="rounded-[16px] bg-card overflow-hidden border-border/40">
<View className="p-4">
<View className="flex-row items-center gap-2 mb-1.5">
<View
className={`w-1.5 h-1.5 rounded-full ${getCategoryColor(item.category)}`}
/>
<Text
variant="muted"
className="text-[10px] font-black uppercase tracking-widest opacity-60"
>
{item.category}
</Text>
</View>
<Text
className="text-foreground font-bold text-sm mb-1"
numberOfLines={2}
>
{item.title}
</Text>
<Text
variant="muted"
className="text-[11px] leading-relaxed"
numberOfLines={2}
>
{item.content}
</Text>
<View className="flex-row items-center gap-3 mt-3">
<View className="flex-row items-center gap-1">
<Clock color="#94a3b8" size={10} strokeWidth={2.5} />
<Text variant="muted" className="text-[10px] font-medium">
{new Date(item.publishedAt).toLocaleDateString()}
</Text>
</View>
</View>
</View>
</Card>
</ShadowWrapper>
</Pressable>
);
return (
<ScreenWrapper className="bg-background">
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 120 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#ea580c"
/>
}
>
<StandardHeader />
{/* Latest News Section */}
<View className="px-5 mt-4">
<Text variant="h4" className="text-foreground tracking-tight mb-4">
Latest News
</Text>
{loadingLatest ? (
<ActivityIndicator color="#ea580c" className="py-10" />
) : latestNews.length > 0 ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToInterval={LATEST_CARD_WIDTH + 16}
className="overflow-visible"
>
{latestNews.map((item) => (
<LatestItem key={item.id} item={item} />
))}
</ScrollView>
) : (
<View className="py-4">
<EmptyState
title="No latest updates"
description="Announcements and important updates will appear here once published."
hint="Pull to refresh to check again."
previewLines={2}
/>
</View>
)}
</View>
{/* All News Section */}
<View className="px-5 mt-8">
<Text variant="h4" className="text-foreground tracking-tight mb-4">
All News
</Text>
{loadingAll ? (
<ActivityIndicator color="#ea580c" className="py-20" />
) : allNews.length > 0 ? (
<>
{allNews.map((item) => (
<NewsItem key={item.id} item={item} />
))}
{hasMore && (
<Pressable
onPress={loadMore}
disabled={loadingMore}
className="py-4 items-center"
>
{loadingMore ? (
<ActivityIndicator color="#ea580c" size="small" />
) : (
<Text className="text-primary font-bold text-xs uppercase tracking-widest">
Load More
</Text>
)}
</Pressable>
)}
</>
) : (
<View className="py-6">
<EmptyState
title="No news yet"
description="Company news, maintenance updates, and announcements will show up here."
hint="Pull to refresh to fetch the latest posts."
previewLines={4}
/>
</View>
)}
</View>
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -1,302 +1,76 @@
import React, { useState, useEffect, useCallback } from "react"; import { View, ScrollView } from 'react-native';
import { import { router } from 'expo-router';
View, import { Text } from '@/components/ui/text';
ScrollView, import { Button } from '@/components/ui/button';
Pressable, import { Card, CardContent } from '@/components/ui/card';
ActivityIndicator, import { MOCK_PAYMENTS } from '@/lib/mock-data';
FlatList, import { ScanLine, Link2, CheckCircle2, Wallet, ChevronRight } from '@/lib/icons';
ListRenderItem,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/lib/api";
import {
ScanLine,
CheckCircle2,
Wallet,
ChevronRight,
AlertTriangle,
Plus,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { toast } from "@/lib/toast-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';
interface Payment {
id: string;
transactionId: string;
amount:
| {
value: number;
currency: string;
}
| number;
currency: string;
paymentDate: string;
paymentMethod: string;
notes: string;
isFlagged: boolean;
flagReason: string;
flagNotes: string;
receiptPath: string;
userId: string;
invoiceId: string;
createdAt: string;
updatedAt: string;
}
export default function PaymentsScreen() { export default function PaymentsScreen() {
const nav = useSirouRouter<AppRoutes>(); const matched = MOCK_PAYMENTS.filter((p) => p.matched);
const permissions = useAuthStore((s) => s.permissions); const pending = MOCK_PAYMENTS.filter((p) => !p.matched);
const [payments, setPayments] = useState<Payment[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
// Check permissions
const canCreatePayments = hasPermission(permissions, PERMISSION_MAP["payments:create"]);
const fetchPayments = useCallback(
async (pageNum: number, isRefresh = false) => {
const { isAuthenticated } = useAuthStore.getState();
if (!isAuthenticated) return;
try {
if (!isRefresh) {
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
}
const response = await api.payments.getAll({
query: { page: pageNum, limit: 10 },
});
const newPayments = response.data;
if (isRefresh) {
setPayments(newPayments);
} else {
setPayments((prev) =>
pageNum === 1 ? newPayments : [...prev, ...newPayments],
);
}
setHasMore(response.meta.hasNextPage);
setPage(pageNum);
} catch (err: any) {
console.error("[Payments] Fetch error:", err);
toast.error("Error", "Failed to fetch payments.");
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
},
[],
);
useEffect(() => {
fetchPayments(1);
}, [fetchPayments]);
const onRefresh = () => {
setRefreshing(true);
fetchPayments(1, true);
};
const loadMore = () => {
if (hasMore && !loadingMore && !loading) {
fetchPayments(page + 1);
}
};
const categorized = {
flagged: payments.filter((p) => p.isFlagged),
pending: payments.filter((p) => !p.invoiceId && !p.isFlagged),
reconciled: payments.filter((p) => p.invoiceId && !p.isFlagged),
};
const renderPaymentItem = (
pay: Payment,
type: "reconciled" | "pending" | "flagged",
) => {
const isReconciled = type === "reconciled";
const isFlagged = type === "flagged";
// Support both object and direct number amount from API
const amountValue =
typeof pay.amount === "object" ? pay.amount.value : pay.amount;
const dateStr = new Date(pay.paymentDate).toLocaleDateString();
return ( return (
<Pressable
key={pay.id}
onPress={() => nav.go("payments/[id]", { id: pay.id })}
className="mb-2"
>
<Card
className={`rounded-[10px] bg-card overflow-hidden ${isReconciled ? "opacity-80" : ""}`}
>
<View className="flex-row items-center p-3">
<View
className={`mr-2 rounded-[6px] p-2 border ${
isFlagged
? "bg-red-500/10 border-red-500/5"
: isReconciled
? "bg-emerald-500/10 border-emerald-500/5"
: "bg-primary/10 border-primary/5"
}`}
>
{isFlagged ? (
<AlertTriangle color="#ef4444" size={18} strokeWidth={2.5} />
) : isReconciled ? (
<CheckCircle2 color="#10b981" size={18} strokeWidth={2.5} />
) : (
<Wallet color={PRIMARY} size={18} strokeWidth={2.5} />
)}
</View>
<View className="flex-1">
<Text variant="p" className="text-foreground font-bold">
{pay.currency || "$"}
{amountValue?.toLocaleString()}
</Text>
<Text variant="muted" className="text-xs">
{pay.paymentMethod} · {dateStr}
</Text>
</View>
{isFlagged ? (
<View className="bg-red-500/10 px-3 py-1 rounded-[6px]">
<Text className="text-red-700 text-[10px] font-semibold">
Flagged
</Text>
</View>
) : !isReconciled ? (
<View className="bg-amber-500/10 px-4 py-2 rounded-[6px]">
<Text className="text-amber-700 text-[10px] font-semibold">
Match
</Text>
</View>
) : (
<ChevronRight size={18} strokeWidth={2} color="#000" />
)}
</View>
</Card>
</Pressable>
);
};
if (loading && page === 1) {
return (
<ScreenWrapper className="bg-background">
<StandardHeader />
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={PRIMARY} />
</View>
</ScreenWrapper>
);
}
return (
<ScreenWrapper className="bg-background">
<ScrollView <ScrollView
className="flex-1" className="flex-1 bg-[#f5f5f5]"
contentContainerStyle={{ paddingBottom: 150 }} contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={({ nativeEvent }) => {
const isCloseToBottom =
nativeEvent.layoutMeasurement.height +
nativeEvent.contentOffset.y >=
nativeEvent.contentSize.height - 20;
if (isCloseToBottom) loadMore();
}}
scrollEventThrottle={400}
> >
<StandardHeader /> <Text className="text-muted-foreground mb-5 text-base">
<View className="px-[16px] pt-6"> Match payment SMS (e.g. bank or Telebirr) to invoices for quick reconciliation.
<Button
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
onPress={() => nav.go("payment-requests/create")}
>
<Plus color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
Create Payment Request
</Text> </Text>
<Button className="mb-5 min-h-12 rounded-xl bg-primary">
<ScanLine color="#ffffff" size={20} strokeWidth={2} />
<Text className="ml-2 text-primary-foreground font-medium">Scan SMS now</Text>
</Button> </Button>
<View className="mb-3 flex-row items-center gap-2">
<Link2 color="#71717a" size={18} strokeWidth={2} />
<Text className="text-muted-foreground text-sm font-medium">Pending match</Text>
</View>
{pending.map((pay) => (
<Card key={pay.id} className="mb-2.5 overflow-hidden rounded-xl border-2 border-amber-500/30 bg-white">
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
<View className="mr-3 rounded-xl bg-primary/10 p-2">
<Wallet color={PRIMARY} size={22} strokeWidth={2} />
</View>
<View className="flex-1">
<Text className="font-semibold text-gray-900">${pay.amount.toLocaleString()}</Text>
<Text className="text-muted-foreground text-sm">{pay.source} · {pay.date}</Text>
</View>
<Button variant="outline" size="sm" className="rounded-lg" onPress={() => router.push(`/payments/${pay.id}`)}>
<Text className="font-medium">Match</Text>
</Button>
</CardContent>
</Card>
))}
{/* Flagged Section */} <View className="mb-3 mt-6 flex-row items-center gap-2">
{categorized.flagged.length > 0 && ( <CheckCircle2 color="#059669" size={18} strokeWidth={2} />
<> <Text className="text-muted-foreground text-sm font-medium">Reconciled</Text>
<View className="mb-4 flex-row items-center gap-3"> </View>
<Text variant="h4" className="text-red-600"> {matched.map((pay) => (
Flagged Payments <Card key={pay.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
<CardContent className="flex-row items-center py-4 pl-4 pr-3">
<View className="mr-3 rounded-xl bg-emerald-500/15 p-2">
<CheckCircle2 color="#059669" size={22} strokeWidth={2} />
</View>
<View className="flex-1">
<Text className="font-semibold text-gray-900">${pay.amount.toLocaleString()}</Text>
<Text className="text-muted-foreground text-sm">
{pay.source} · {pay.date} {pay.reference && `· ${pay.reference}`}
</Text> </Text>
</View> </View>
<View className="gap-2 mb-6"> <View className="rounded-full bg-emerald-500/20 px-2.5 py-1">
{categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))} <Text className="text-xs font-medium text-emerald-700">Matched</Text>
</View>
</>
)}
{/* Pending Section */}
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-foreground">
Pending Match
</Text>
</View>
<View className="gap-2 mb-6">
{categorized.pending.length > 0 ? (
categorized.pending.map((p) => renderPaymentItem(p, "pending"))
) : (
<View className="py-1">
<EmptyState
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>
{/* Reconciled Section */}
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-foreground">
Reconciled
</Text>
</View>
<View className="gap-2">
{categorized.reconciled.length > 0 ? (
categorized.reconciled.map((p) =>
renderPaymentItem(p, "reconciled"),
)
) : (
<View className="py-4">
<EmptyState
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>
{loadingMore && (
<View className="py-4">
<ActivityIndicator color={PRIMARY} />
</View>
)}
</View> </View>
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
</CardContent>
</Card>
))}
</ScrollView> </ScrollView>
</ScreenWrapper>
); );
} }

119
app/(tabs)/profile.tsx Normal file
View File

@ -0,0 +1,119 @@
import { View, ScrollView, Pressable } from 'react-native';
import { router } from 'expo-router';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { MOCK_USER } from '@/lib/mock-data';
import { User, Mail, Globe, Bell, ChevronRight, Info, LogOut, LogIn, FileText, FolderOpen, Settings } from '@/lib/icons';
const PRIMARY = '#ea580c';
export default function ProfileScreen() {
return (
<ScrollView
className="flex-1 bg-[#f5f5f5]"
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
showsVerticalScrollIndicator={false}
>
<View className="mb-6 items-center">
<View className="mb-3 h-20 w-20 items-center justify-center rounded-full bg-primary">
<Text className="text-3xl font-bold text-primary-foreground">{MOCK_USER.name[0]}</Text>
</View>
<Text className="text-xl font-semibold text-gray-900">{MOCK_USER.name}</Text>
<Text className="text-muted-foreground mt-1 text-sm">{MOCK_USER.email}</Text>
</View>
<Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
<CardHeader className="pb-2">
<CardTitle className="text-base">Account</CardTitle>
</CardHeader>
<CardContent className="gap-0">
<View className="flex-row items-center justify-between border-b border-border py-3">
<View className="flex-row items-center gap-3">
<Mail color="#71717a" size={20} strokeWidth={2} />
<Text className="text-muted-foreground">Email</Text>
</View>
<Text className="text-gray-900">{MOCK_USER.email}</Text>
</View>
<View className="flex-row items-center justify-between border-b border-border py-3">
<View className="flex-row items-center gap-3">
<Globe color="#71717a" size={20} strokeWidth={2} />
<Text className="text-muted-foreground">Language</Text>
</View>
<Text className="text-gray-900">English</Text>
</View>
<Pressable
onPress={() => router.push('/notifications')}
className="flex-row items-center justify-between border-b border-border py-3"
>
<View className="flex-row items-center gap-3">
<Bell color="#71717a" size={20} strokeWidth={2} />
<Text className="text-muted-foreground">Notifications</Text>
</View>
<View className="flex-row items-center gap-1">
<Text className="text-primary font-medium">Manage</Text>
<ChevronRight color={PRIMARY} size={18} strokeWidth={2} />
</View>
</Pressable>
<Pressable
onPress={() => router.push('/reports')}
className="flex-row items-center justify-between border-b border-border py-3"
>
<View className="flex-row items-center gap-3">
<FileText color="#71717a" size={20} strokeWidth={2} />
<Text className="text-muted-foreground">Reports</Text>
</View>
<ChevronRight color="#a1a1aa" size={18} strokeWidth={2} />
</Pressable>
<Pressable
onPress={() => router.push('/documents')}
className="flex-row items-center justify-between border-b border-border py-3"
>
<View className="flex-row items-center gap-3">
<FolderOpen color="#71717a" size={20} strokeWidth={2} />
<Text className="text-muted-foreground">Documents</Text>
</View>
<ChevronRight color="#a1a1aa" size={18} strokeWidth={2} />
</Pressable>
<Pressable
onPress={() => router.push('/settings')}
className="flex-row items-center justify-between py-3"
>
<View className="flex-row items-center gap-3">
<Settings color="#71717a" size={20} strokeWidth={2} />
<Text className="text-muted-foreground">Settings</Text>
</View>
<ChevronRight color="#a1a1aa" size={18} strokeWidth={2} />
</Pressable>
</CardContent>
</Card>
<Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
<CardHeader className="pb-2">
<View className="flex-row items-center gap-2">
<Info color="#71717a" size={18} strokeWidth={2} />
<CardTitle className="text-base">About</CardTitle>
</View>
</CardHeader>
<CardContent>
<Text className="text-muted-foreground text-sm leading-5">
Yaltopia Tickets App Scan. Send. Reconcile. Companion to the Yaltopia Tickets web app.
</Text>
</CardContent>
</Card>
<Button
variant="outline"
className="mt-2 min-h-12 rounded-xl border-border"
onPress={() => router.push('/login')}
>
<LogIn color={PRIMARY} size={20} strokeWidth={2} />
<Text className="ml-2 font-medium text-gray-700">Sign in (different account)</Text>
</Button>
<Button variant="destructive" className="mt-3 min-h-12 rounded-xl">
<LogOut color="#ffffff" size={20} strokeWidth={2} />
<Text className="ml-2 font-medium">Log out</Text>
</Button>
</ScrollView>
);
}

View File

@ -1,251 +1,55 @@
import React, { useState, useEffect, useCallback } from "react"; import { View, ScrollView, Pressable } from 'react-native';
import { import { Text } from '@/components/ui/text';
View, import { Button } from '@/components/ui/button';
Pressable, import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
ActivityIndicator, import { MOCK_PROFORMA } from '@/lib/mock-data';
FlatList, import { router } from 'expo-router';
ListRenderItem, import { Plus, Send, FileText, ChevronRight, Calendar } from '@/lib/icons';
} from "react-native";
import { Text } from "@/components/ui/text";
import { Card } from "@/components/ui/card";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Plus, Send, FileText, Clock, ChevronRight } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/EmptyState";
import { api } from "@/lib/api";
import { useAuthStore } from "@/lib/auth-store";
import { PERMISSION_MAP, hasPermission } from "@/lib/permissions";
interface ProformaItem { const PRIMARY = '#ea580c';
id: string;
proformaNumber: string;
customerName: string;
customerEmail: string;
customerPhone: string;
amount: any;
currency: string;
issueDate: string;
dueDate: 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 permissions = useAuthStore((s) => s.permissions);
const [proformas, setProformas] = useState<ProformaItem[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
// Check permissions
const canCreateProformas = hasPermission(permissions, PERMISSION_MAP["proforma:create"]);
const fetchProformas = useCallback(
async (pageNum: number, isRefresh = false) => {
const { isAuthenticated } = useAuthStore.getState();
if (!isAuthenticated) return;
try {
if (!isRefresh) {
pageNum === 1 ? setLoading(true) : setLoadingMore(true);
}
const response = await api.proforma.getAll({
query: { page: pageNum, limit: 10 },
});
let newProformas = response.data;
const newData = newProformas;
if (isRefresh) {
setProformas(newData);
} else {
setProformas((prev) =>
pageNum === 1 ? newData : [...prev, ...newData],
);
}
setHasMore(response.meta.hasNextPage);
setPage(pageNum);
} catch (err: any) {
console.error("[Proforma] Fetch error:", err);
setHasMore(false);
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
},
[],
);
useEffect(() => {
fetchProformas(1);
}, [fetchProformas]);
const onRefresh = () => {
setRefreshing(true);
fetchProformas(1, true);
};
const loadMore = () => {
if (hasMore && !loadingMore && !loading) {
fetchProformas(page + 1);
}
};
const renderProformaItem: ListRenderItem<ProformaItem> = ({ item }) => {
const amountVal =
typeof item.amount === "object" ? item.amount.value : item.amount;
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]"> <ScrollView
<Pressable className="flex-1 bg-[#f5f5f5]"
onPress={() => nav.go("proforma/[id]", { id: item.id })} contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
className="mb-3" showsVerticalScrollIndicator={false}
> >
<Card className="rounded-[12px] bg-card overflow-hidden border border-border/40"> <Text className="text-muted-foreground mb-5 text-base">
<View className="p-4"> Create or select proforma requests and share with contacts via email or SMS.
<View className="flex-row items-start">
<View className="bg-primary/10 p-2.5 rounded-[12px] border border-primary/10 mr-3">
<FileText color="#ea580c" size={18} strokeWidth={2.5} />
</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">
<Text className="text-foreground font-bold text-base">
{item.currency || "$"}
{amountVal?.toLocaleString?.() ?? amountVal ?? "0"}
</Text> </Text>
</View> <Button className="mb-5 min-h-12 rounded-xl bg-primary" onPress={() => {}}>
</View> <Plus color="#ffffff" size={20} strokeWidth={2} />
<Text className="ml-2 text-primary-foreground font-medium">Create new proforma</Text>
<View className="mt-2 flex-row items-center justify-between"> </Button>
<Text variant="muted" className="text-[10px] font-medium">
Issued: {issuedStr} | Due: {dueStr} | {itemsCount} item{itemsCount !== 1 ? "s" : ""}
</Text>
</View>
<View className="mb-3 flex-row items-center gap-2">
<FileText color="#71717a" size={18} strokeWidth={2} />
<Text className="text-muted-foreground text-sm font-medium">Your proforma requests</Text>
</View> </View>
{MOCK_PROFORMA.map((pf) => (
<Pressable key={pf.id} onPress={() => router.push(`/proforma/${pf.id}`)}>
<Card className="mb-3 overflow-hidden rounded-xl border border-border bg-white">
<CardHeader className="pb-2">
<CardTitle className="text-base">{pf.title}</CardTitle>
<CardDescription className="mt-0.5">{pf.description}</CardDescription>
<View className="mt-2 flex-row items-center gap-1.5">
<Calendar color="#71717a" size={14} strokeWidth={2} />
<Text className="text-muted-foreground text-xs">Deadline {pf.deadline} · {pf.itemCount} items</Text>
</View> </View>
</CardHeader>
<CardFooter className="flex-row items-center justify-between border-t border-border pt-3">
<Text className="text-muted-foreground text-sm">Sent to {pf.sentCount} contacts</Text>
<View className="flex-row items-center gap-1.5">
<Send color={PRIMARY} size={16} strokeWidth={2} />
<Text className="text-primary font-medium text-sm">Send to contacts</Text>
<ChevronRight color="#a1a1aa" size={18} strokeWidth={2} />
</View> </View>
</CardFooter>
</Card> </Card>
</Pressable> </Pressable>
</View> ))}
); </ScrollView>
};
return (
<ScreenWrapper className="bg-background">
<FlatList
data={proformas}
renderItem={renderProformaItem}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
onRefresh={onRefresh}
refreshing={refreshing}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListHeaderComponent={
<>
<StandardHeader />
<View className="px-[16px] pt-6">
{/* {canCreateProformas && ( */}
<Button
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
onPress={() => nav.go("proforma/create")}
>
<Plus color="white" size={20} strokeWidth={3} />
<Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
Create New Proforma
</Text>
</Button>
{/* )} */}
</View>
</>
}
ListFooterComponent={
loadingMore ? (
<ActivityIndicator color="#ea580c" className="py-4" />
) : null
}
ListEmptyComponent={
!loading ? (
<View className="px-[16px] py-6">
<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 className="py-20">
<ActivityIndicator size="large" color="#ea580c" />
</View>
)
}
/>
</ScreenWrapper>
); );
} }

View File

@ -1,208 +1,66 @@
import React, { useState, useEffect, useRef } from "react"; import { View, ScrollView } from 'react-native';
import { import { Text } from '@/components/ui/text';
View, import { Button } from '@/components/ui/button';
Pressable, import { Card, CardContent } from '@/components/ui/card';
Platform, import { Camera, FileText, ChevronRight } from '@/lib/icons';
ActivityIndicator,
Alert,
} from "react-native";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { X, Zap, Camera as CameraIcon, ScanLine } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { CameraView, useCameraPermissions } from "expo-camera";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { useNavigation } from "expo-router";
import { BASE_URL } from "@/lib/api";
import { useAuthStore } from "@/lib/auth-store";
import { toast } from "@/lib/toast-store";
const NAV_BG = "#ffffff"; const PRIMARY = '#ea580c';
export default function ScanScreen() { export default function ScanScreen() {
const nav = useSirouRouter<AppRoutes>();
const [permission, requestPermission] = useCameraPermissions();
const [torch, setTorch] = useState(false);
const [scanning, setScanning] = useState(false);
const cameraRef = useRef<CameraView>(null);
const navigation = useNavigation();
const token = useAuthStore((s) => s.token);
useEffect(() => {
navigation.setOptions({ tabBarStyle: { display: "none" } });
return () => {
navigation.setOptions({
tabBarStyle: {
display: "flex",
backgroundColor: NAV_BG,
borderTopWidth: 0,
elevation: 10,
height: 75,
paddingBottom: Platform.OS === "ios" ? 30 : 10,
paddingTop: 10,
marginHorizontal: 20,
position: "absolute",
bottom: 25,
left: 20,
right: 20,
borderRadius: 32,
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.12,
shadowRadius: 20,
},
});
};
}, [navigation]);
const handleScan = async () => {
if (!cameraRef.current || scanning) return;
setScanning(true);
try {
// 1. Capture the photo
const photo = await cameraRef.current.takePictureAsync({
quality: 0.85,
base64: false,
});
if (!photo?.uri) throw new Error("Failed to capture photo.");
toast.info("Scanning...", "Uploading invoice image for AI extraction.");
// 2. Build multipart form data with the image file
const formData = new FormData();
formData.append("file", {
uri: photo.uri,
name: "invoice.jpg",
type: "image/jpeg",
} as any);
// 3. POST to /api/v1/scan/invoice
const response = await fetch(`${BASE_URL}scan/invoice`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
// Do NOT set Content-Type here — fetch sets it automatically with the boundary for multipart
},
body: formData,
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || "Scan failed.");
}
const data = await response.json();
console.log("[Scan] Extracted invoice data:", data);
toast.success("Scan Complete!", "Invoice data extracted successfully.");
// Navigate to create invoice screen
nav.go("proforma/create");
} catch (err: any) {
console.error("[Scan] Error:", err);
toast.error(
"Scan Failed",
err.message || "Could not process the invoice.",
);
} finally {
setScanning(false);
}
};
if (!permission) {
return <View className="flex-1 bg-black" />;
}
if (!permission.granted) {
return ( return (
<ScreenWrapper className="bg-background items-center justify-center"> <ScrollView
<View className="bg-primary/10 p-6 rounded-[24px] mb-6"> className="flex-1 bg-[#f5f5f5]"
<CameraIcon className="text-primary" size={48} strokeWidth={1.5} /> contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
</View> showsVerticalScrollIndicator={false}
<Text variant="h2" className="text-center mb-2">
Camera Access
</Text>
<Text variant="muted" className="text-center mb-10 leading-6 px-10">
We need your permission to use the camera to scan invoices and
receipts automatically.
</Text>
<Button
className="w-3/4 h-14 rounded-[12px] bg-primary px-10"
onPress={requestPermission}
> >
<Text className="text-white font-bold uppercase tracking-widest"> <Text className="text-muted-foreground mb-5 text-base">
Enable Camera Capture paper or digital invoices with your camera. We'll extract vendor, amount, date, and line items.
</Text> </Text>
<Card className="mb-5 overflow-hidden rounded-2xl border-2 border-dashed border-border bg-white">
<CardContent className="items-center justify-center py-14">
<View className="mb-5 h-24 w-24 items-center justify-center rounded-full bg-primary/10">
<Camera color={PRIMARY} size={40} strokeWidth={2} />
</View>
<Text className="mb-2 text-center text-lg font-semibold text-gray-900">Scan invoice</Text>
<Text className="text-muted-foreground mb-6 text-center text-sm">
Tap below to open camera and capture an invoice
</Text>
<Button className="min-h-12 rounded-xl bg-primary px-8">
<Camera color="#ffffff" size={20} strokeWidth={2} />
<Text className="ml-2 text-primary-foreground font-medium">Open camera</Text>
</Button> </Button>
<Pressable onPress={() => nav.back()} className="mt-6"> </CardContent>
<Text className="text-muted-foreground font-bold">Go Back</Text> </Card>
</Pressable>
</ScreenWrapper>
);
}
return ( <View className="mb-3 flex-row items-center gap-2">
<View className="flex-1 bg-black"> <FileText color="#71717a" size={18} strokeWidth={2} />
<CameraView <Text className="text-muted-foreground text-sm font-medium">Recent scans</Text>
ref={cameraRef}
style={{ flex: 1 }}
facing="back"
enableTorch={torch}
>
<View className="flex-1 justify-between p-10 pt-16">
{/* Top bar */}
<View className="flex-row justify-between items-center">
<Pressable
onPress={() => setTorch(!torch)}
className={`h-12 w-12 rounded-full items-center justify-center border border-white/20 ${torch ? "bg-primary" : "bg-black/40"}`}
>
<Zap
color="white"
size={20}
fill={torch ? "white" : "transparent"}
/>
</Pressable>
<Pressable
onPress={() => nav.back()}
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
>
<X color="white" size={24} />
</Pressable>
</View> </View>
<Card className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
{/* Scan Frame */} <CardContent className="flex-row items-center py-4 pl-4 pr-3">
<View className="items-center mt-10"> <View className="flex-1">
<View className="w-[300px] h-[500px] border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center"> <Text className="font-medium text-gray-900">Acme Corp - Invoice #101</Text>
<View className="w-[280px] h-[480px] border border-white/10 rounded-2xl" /> <Text className="text-muted-foreground mt-0.5 text-sm">Scanned Sep 12, 2022 · $1,240</Text>
</View> </View>
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs"> <View className="rounded-full bg-amber-500/20 px-2.5 py-1">
Align Invoice Within Frame <Text className="text-xs font-medium text-amber-700">Pending</Text>
</Text>
</View> </View>
<ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
{/* Capture Button */} </CardContent>
<View className="items-center pb-10 gap-4"> </Card>
<Pressable <Card className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
onPress={handleScan} <CardContent className="flex-row items-center py-4 pl-4 pr-3">
disabled={scanning} <View className="flex-1">
className="h-20 w-20 rounded-full bg-primary items-center justify-center border-4 border-white/30" <Text className="font-medium text-gray-900">Tech Supplies Ltd - Invoice #88</Text>
> <Text className="text-muted-foreground mt-0.5 text-sm">Scanned Sep 11, 2022 · $890</Text>
{scanning ? (
<ActivityIndicator color="white" size="large" />
) : (
<ScanLine color="white" size={32} />
)}
</Pressable>
<Text className="text-white/50 text-[10px] font-black uppercase tracking-widest">
{scanning ? "Extracting Data..." : "Tap to Scan"}
</Text>
</View> </View>
<View className="rounded-full bg-emerald-500/20 px-2.5 py-1">
<Text className="text-xs font-medium text-emerald-700">Saved</Text>
</View> </View>
</CameraView> <ChevronRight className="ml-2 text-muted-foreground" color="#71717a" size={20} strokeWidth={2} />
</View> </CardContent>
</Card>
</ScrollView>
); );
} }

View File

@ -1,218 +1,38 @@
import React, { useEffect, useState } from "react"; import '../global.css';
import { Stack } from "expo-router"; import { Stack } from 'expo-router';
import { StatusBar } from "expo-status-bar"; import { StatusBar } from 'expo-status-bar';
import { PortalHost } from "@rn-primitives/portal"; import { PortalHost } from '@rn-primitives/portal';
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Toast } from "@/components/Toast"; import { SafeAreaProvider } from 'react-native-safe-area-context';
import "@/global.css"; import { View } from 'react-native';
import { SafeAreaProvider } from "react-native-safe-area-context";
import { View, ActivityIndicator, InteractionManager } from "react-native";
import { useRestoreTheme, NAV_THEME } from "@/lib/theme";
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
import { NavigationContainer, NavigationIndependentTree, ThemeProvider } from "@react-navigation/native";
import { routes } from "@/lib/routes";
import { authGuard, guestGuard } from "@/lib/auth-guards";
import { useAuthStore } from "@/lib/auth-store";
import { useFonts } from 'expo-font';
import { api } from "@/lib/api";
import { useColorScheme } from 'react-native';
import { useSegments } from "expo-router";
function BackupGuard() {
const segments = useSegments();
const isAuthed = useAuthStore((s) => s.isAuthenticated);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
// Intentionally disabled: redirecting here can happen before the root layout
// navigator is ready and cause "Attempted to navigate before mounting".
// Sirou guards handle redirects.
}, [segments, isAuthed, isMounted]);
return null;
}
function SirouBridge() {
const sirou = useSirouRouter();
const segments = useSegments();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
const checkAuth = async () => {
// Create EXACT name from segments: (tabs), index => (tabs)/index
// Use "root" if segments are empty (initial layout)
const routeName = segments.length > 0 ? segments.join("/") : "root";
console.log(`[SirouBridge] checking route: "${routeName}"`);
try {
const result = await (sirou as any).checkGuards(routeName);
if (!result.allowed && result.redirect) {
console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`);
// Use Sirou navigation safely
InteractionManager.runAfterInteractions(() => {
sirou.go(result.redirect);
});
}
} catch (e: any) {
console.warn(
`[SirouBridge] guard crash for "${routeName}":`,
e.message,
);
}
};
checkAuth();
}, [segments, sirou, isMounted, isAuthenticated]);
return null;
}
export default function RootLayout() { export default function RootLayout() {
const colorScheme = useColorScheme();
useRestoreTheme();
const [isMounted, setIsMounted] = 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(() => {
setIsMounted(true);
const initializeAuth = async () => {
if (useAuthStore.persist.hasHydrated()) {
setHasHydrated(true);
} else {
const unsub = useAuthStore.persist.onFinishHydration(() => {
setHasHydrated(true);
});
return unsub;
}
};
initializeAuth();
}, []);
if (!isMounted || !hasHydrated || !fontsLoaded) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 1)",
}}
>
<ActivityIndicator size="large" color="#ea580c" />
</View>
);
}
return ( return (
<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={colorScheme === "dark" ? "light" : "dark"} /> <StatusBar style="light" />
<Stack <Stack
screenOptions={{ screenOptions={{
headerShown: false, headerStyle: { backgroundColor: '#2d2d2d' },
headerTintColor: '#ffffff',
headerTitleStyle: { fontWeight: '600' },
}} }}
> >
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen <Stack.Screen name="proforma/[id]" options={{ title: 'Proforma request' }} />
name="sms-scan" <Stack.Screen name="payments/[id]" options={{ title: 'Payment' }} />
options={{ headerShown: false }} <Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
/> <Stack.Screen name="notifications/settings" options={{ title: 'Notification settings' }} />
<Stack.Screen <Stack.Screen name="login" options={{ title: 'Sign in', headerShown: false }} />
name="proforma/[id]" <Stack.Screen name="register" options={{ title: 'Create account', headerShown: false }} />
options={{ title: "Proforma request" }} <Stack.Screen name="invoices/[id]" options={{ title: 'Invoice' }} />
/> <Stack.Screen name="reports" options={{ title: 'Reports' }} />
<Stack.Screen <Stack.Screen name="documents" options={{ title: 'Documents' }} />
name="payments/[id]" <Stack.Screen name="settings" options={{ title: 'Settings' }} />
options={{ title: "Payment" }}
/>
<Stack.Screen
name="notifications/index"
options={{ title: "Notifications" }}
/>
<Stack.Screen
name="notifications/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
name="login"
options={{ title: "Sign in", headerShown: false }}
/>
<Stack.Screen
name="register"
options={{ title: "Create account", headerShown: false }}
/>
<Stack.Screen
name="invoices/[id]"
options={{ title: "Invoice" }}
/>
<Stack.Screen
name="reports/index"
options={{ title: "Reports" }}
/>
<Stack.Screen
name="documents/index"
options={{ title: "Documents" }}
/>
<Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="profile" options={{ headerShown: false }} />
<Stack.Screen
name="edit-profile"
options={{ headerShown: false }}
/>
</Stack> </Stack>
<SirouBridge />
<BackupGuard />
<PortalHost /> <PortalHost />
<Toast />
</View> </View>
</ThemeProvider>
</SirouRouterProvider>
</NavigationContainer>
</NavigationIndependentTree>
</SafeAreaProvider> </SafeAreaProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );

View File

@ -1,220 +0,0 @@
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

@ -1,169 +0,0 @@
import React, { useState, useEffect } from "react";
import {
View,
ScrollView,
Pressable,
TextInput,
ActivityIndicator,
RefreshControl,
useColorScheme,
} from "react-native";
import { Text } from "@/components/ui/text";
import { Card, CardContent } from "@/components/ui/card";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack } from "expo-router";
import { useAuthStore } from "@/lib/auth-store";
import { api } from "@/lib/api";
import { getPlaceholderColor } from "@/lib/colors";
import {
UserPlus,
Search,
Mail,
Phone,
ChevronRight,
Briefcase,
Info,
} from "@/lib/icons";
export default function CompanyScreen() {
const nav = useSirouRouter<AppRoutes>();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [workers, setWorkers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const fetchWorkers = async () => {
try {
const response = await api.users.getAll();
setWorkers(response.data || []);
} catch (error) {
console.error("[CompanyScreen] Error fetching workers:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchWorkers();
}, []);
const onRefresh = () => {
setRefreshing(true);
fetchWorkers();
};
const filteredWorkers = workers.filter((worker) => {
const name = `${worker.firstName} ${worker.lastName}`.toLowerCase();
const email = (worker.email || "").toLowerCase();
const query = searchQuery.toLowerCase();
return name.includes(query) || email.includes(query);
});
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Company" showBack rightAction="companyInfo" />
<View className="flex-1 px-5 pt-4">
{/* Search Bar */}
<ShadowWrapper level="xs">
<View className="flex-row items-center bg-card rounded-xl px-4 border border-border h-12 mb-6">
<Search size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="Search workers..."
placeholderTextColor={getPlaceholderColor(isDark)}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
</ShadowWrapper>
{/* Worker List Header */}
<View className="flex-row justify-between items-center mb-4">
<Text variant="h4" className="text-foreground tracking-tight">
Workers ({filteredWorkers.length})
</Text>
</View>
{loading ? (
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" />
</View>
) : (
<ScrollView
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#ea580c"
/>
}
contentContainerStyle={{ paddingBottom: 100 }}
>
{filteredWorkers.length > 0 ? (
filteredWorkers.map((worker) => (
<ShadowWrapper key={worker.id} level="xs">
<Card className="mb-3 overflow-hidden rounded-[12px] bg-card border-0">
<CardContent className="flex-row items-center p-4">
<View className="h-12 w-12 rounded-full bg-secondary/50 items-center justify-center mr-4">
<Text className="text-primary font-bold text-lg">
{worker.firstName?.[0]}
{worker.lastName?.[0]}
</Text>
</View>
<View className="flex-1">
<Text className="text-foreground font-bold text-base">
{worker.firstName} {worker.lastName}
</Text>
<View className="flex-row items-center mt-1">
<Text className="text-muted-foreground text-xs bg-secondary px-2 py-0.5 rounded-md uppercase font-bold tracking-widest text-[10px]">
{worker.role || "WORKER"}
</Text>
</View>
</View>
<ChevronRight
size={18}
color={isDark ? "#334155" : "#cbd5e1"}
/>
</CardContent>
</Card>
</ShadowWrapper>
))
) : (
<View className="py-20 items-center">
<Briefcase
size={48}
color={isDark ? "#1e293b" : "#f1f5f9"}
strokeWidth={1}
/>
<Text variant="muted" className="mt-4">
No workers found
</Text>
</View>
)}
</ScrollView>
)}
</View>
{/* Floating Action Button */}
<Pressable
onPress={() => nav.go("user/create")}
className="absolute bottom-8 right-8 h-14 w-14 bg-primary rounded-full items-center justify-center shadow-lg shadow-primary/40"
>
<UserPlus size={24} color="white" strokeWidth={2.5} />
</Pressable>
</ScreenWrapper>
);
}

View File

@ -1,16 +1,14 @@
import { View, ScrollView, Pressable } from "react-native"; import { View, ScrollView, Pressable } from 'react-native';
import { useSirouRouter } from "@sirou/react-native"; import { router } from 'expo-router';
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 { Button } from '@/components/ui/button';
import { Button } from "@/components/ui/button"; import { FileText, ChevronRight, FolderOpen, Upload } from '@/lib/icons';
import { FileText, ChevronRight, FolderOpen, Upload } from "@/lib/icons"; import { MOCK_DOCUMENTS } from '@/lib/mock-data';
import { MOCK_DOCUMENTS } from "@/lib/mock-data";
const PRIMARY = "#ea580c"; const PRIMARY = '#ea580c';
export default function DocumentsScreen() { export default function DocumentsScreen() {
const nav = useSirouRouter<AppRoutes>();
return ( return (
<ScrollView <ScrollView
className="flex-1 bg-[#f5f5f5]" className="flex-1 bg-[#f5f5f5]"
@ -25,32 +23,21 @@ export default function DocumentsScreen() {
Uploaded invoices, scans, and attachments. Synced with your account. Uploaded invoices, scans, and attachments. Synced with your account.
</Text> </Text>
<Button <Button variant="outline" className="mb-5 min-h-12 rounded-xl border-border" onPress={() => {}}>
variant="outline"
className="mb-5 min-h-12 rounded-xl border-border"
onPress={() => {}}
>
<Upload color={PRIMARY} size={20} strokeWidth={2} /> <Upload color={PRIMARY} size={20} strokeWidth={2} />
<Text className="ml-2 font-medium text-gray-700">Upload document</Text> <Text className="ml-2 font-medium text-gray-700">Upload document</Text>
</Button> </Button>
{MOCK_DOCUMENTS.map((d) => ( {MOCK_DOCUMENTS.map((d) => (
<Card <Card key={d.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
key={d.id}
className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"
>
<Pressable> <Pressable>
<CardContent className="flex-row items-center py-4 pl-4 pr-3"> <CardContent className="flex-row items-center py-4 pl-4 pr-3">
<View className="mr-3 rounded-xl bg-primary/10 p-2"> <View className="mr-3 rounded-xl bg-primary/10 p-2">
<FileText color={PRIMARY} size={22} strokeWidth={2} /> <FileText color={PRIMARY} size={22} strokeWidth={2} />
</View> </View>
<View className="flex-1"> <View className="flex-1">
<Text className="font-medium text-gray-900" numberOfLines={1}> <Text className="font-medium text-gray-900" numberOfLines={1}>{d.name}</Text>
{d.name} <Text className="text-muted-foreground mt-0.5 text-sm">{d.size} · {d.uploadedAt}</Text>
</Text>
<Text className="text-muted-foreground mt-0.5 text-sm">
{d.size} · {d.uploadedAt}
</Text>
</View> </View>
<ChevronRight color="#71717a" size={20} strokeWidth={2} /> <ChevronRight color="#71717a" size={20} strokeWidth={2} />
</CardContent> </CardContent>
@ -58,11 +45,7 @@ export default function DocumentsScreen() {
</Card> </Card>
))} ))}
<Button <Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
variant="outline"
className="mt-4 rounded-xl border-border"
onPress={() => nav.back()}
>
<Text className="font-medium">Back</Text> <Text className="font-medium">Back</Text>
</Button> </Button>
</ScrollView> </ScrollView>

View File

@ -1,162 +0,0 @@
import React, { useState } from "react";
import {
View,
ScrollView,
Pressable,
TextInput,
ActivityIndicator,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ArrowLeft, User, Check, X } from "@/lib/icons";
import { useAuthStore } from "@/lib/auth-store";
import { api } from "@/lib/api";
import { useToast } from "@/lib/toast-store";
import { useColorScheme } from "nativewind";
export default function EditProfileScreen() {
const nav = useSirouRouter<AppRoutes>();
const { user, updateUser } = useAuthStore();
const { showToast } = useToast();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [loading, setLoading] = useState(false);
const [firstName, setFirstName] = useState(user?.firstName || "");
const [lastName, setLastName] = useState(user?.lastName || "");
const handleSave = async () => {
if (!firstName.trim() || !lastName.trim()) {
showToast("First and last name are required", "error");
return;
}
setLoading(true);
try {
const response = await api.users.updateProfile({
body: {
firstName: firstName.trim(),
lastName: lastName.trim(),
},
});
// Update local store with the returned user data
updateUser(response);
showToast("Profile updated successfully", "success");
nav.back();
} catch (e: any) {
console.error("[EditProfile] Update failed:", e);
showToast(e.message || "Failed to update profile", "error");
} finally {
setLoading(false);
}
};
return (
<ScreenWrapper className="bg-background">
{/* Header */}
<View className="px-6 pt-4 flex-row justify-between items-center">
<Pressable
onPress={() => nav.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<ArrowLeft color={isDark ? "#fff" : "#0f172a"} size={20} />
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
Edit Profile
</Text>
<View className="w-10" /> {/* Spacer */}
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 24,
paddingTop: 32,
paddingBottom: 40,
}}
>
<View className="gap-6">
{/* First Name */}
<View>
<Text
variant="small"
className="font-semibold mb-2 ml-1 text-foreground/70"
>
First Name
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-14">
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground text-base h-12"
placeholder="Enter first name"
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
value={firstName}
onChangeText={setFirstName}
autoCorrect={false}
/>
{firstName.trim().length > 0 && (
<Check size={16} color="#10b981" />
)}
</View>
</View>
{/* Last Name */}
<View>
<Text
variant="small"
className="font-semibold mb-2 ml-1 text-foreground/70"
>
Last Name
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-14">
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground text-base h-12"
placeholder="Enter last name"
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
value={lastName}
onChangeText={setLastName}
autoCorrect={false}
style={{ textAlignVertical: "center" }}
/>
{lastName.trim().length > 0 && (
<Check size={16} color="#10b981" />
)}
</View>
</View>
<View className="mt-8 gap-3">
<Button
className="h-10 bg-primary rounded-[6px] shadow-lg shadow-primary/30"
onPress={handleSave}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text className="text-white font-bold text-sm">
Save Changes
</Text>
)}
</Button>
<Pressable
onPress={() => nav.back()}
className="h-10 border border-border items-center justify-center"
disabled={loading}
>
<Text className="text-muted-foreground font-semibold">
Cancel
</Text>
</Pressable>
</View>
</View>
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -1,59 +0,0 @@
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

@ -1,194 +0,0 @@
import React, { useState, useEffect } from "react";
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text";
import { Card, CardContent } from "@/components/ui/card";
import {
FileText,
ChevronRight,
TrendingUp,
TrendingDown,
Clock,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { EmptyState } from "@/components/EmptyState";
import { api } from "@/lib/api";
import { Stack } from "expo-router";
export default function HistoryScreen() {
const nav = useSirouRouter<AppRoutes>();
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({ totalRevenue: 0, pending: 0 });
const [invoices, setInvoices] = useState<any[]>([]);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const [statsRes, invoicesRes] = await Promise.all([
api.invoices.stats(),
api.invoices.getAll({ query: { limit: 100 } }),
]);
setStats(statsRes);
setInvoices(invoicesRes.data || []);
} catch (error) {
console.error("[HistoryScreen] Error fetching history:", error);
} finally {
setLoading(false);
}
};
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Activity History" showBack />
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
>
<View className="flex-row gap-4 mb-10">
<ShadowWrapper className="flex-1">
<View className="bg-card rounded-[10px] p-4 border border-border/5">
<View className="h-10 w-10 bg-emerald-500/10 rounded-[8px] items-center justify-center mb-3">
<TrendingUp color="#10b981" size={20} strokeWidth={2.5} />
</View>
<Text
variant="muted"
className="font-bold text-[10px] uppercase tracking-widest opacity-60"
>
Total Inflow
</Text>
<Text variant="h3" className="text-foreground font-black mt-1">
${stats.totalRevenue.toLocaleString()}
</Text>
</View>
</ShadowWrapper>
<ShadowWrapper className="flex-1">
<View className="bg-card rounded-[10px] p-4 border border-border/5">
<View className="h-10 w-10 bg-amber-500/10 rounded-[8px] items-center justify-center mb-3">
<TrendingDown color="#f59e0b" size={20} strokeWidth={2.5} />
</View>
<Text
variant="muted"
className="font-bold text-[10px] uppercase tracking-widest opacity-60"
>
Pending
</Text>
<Text variant="h3" className="text-foreground font-black mt-1">
${stats.pending.toLocaleString()}
</Text>
</View>
</ShadowWrapper>
</View>
<Text variant="h4" className="text-foreground mb-4 tracking-tight">
All Activity
</Text>
{loading ? (
<View className="py-20 items-center">
<ActivityIndicator color="#ea580c" />
</View>
) : (
<View className="gap-2">
{invoices.length > 0 ? (
invoices.map((inv) => (
<Pressable
key={inv.id}
onPress={() => nav.go("invoices/[id]", { id: inv.id })}
>
<ShadowWrapper level="xs">
<Card className="rounded-[8px] bg-card overflow-hidden border-0">
<CardContent className="flex-row items-center py-4 px-3">
<View className="bg-secondary/40 h-10 w-10 rounded-[8px] items-center justify-center mr-3 border border-border/10">
<FileText
size={20}
color="#ea580c"
strokeWidth={2.5}
/>
</View>
<View className="flex-1">
<Text
variant="p"
className="text-foreground font-bold"
>
{inv.customerName}
</Text>
<Text
variant="muted"
className="text-[11px] font-medium opacity-60"
>
{new Date(inv.issueDate).toLocaleDateString()} ·
Proforma
</Text>
</View>
<View className="items-end">
<Text
variant="p"
className="text-foreground font-black"
>
${Number(inv.amount).toLocaleString()}
</Text>
<View
className={`mt-1 rounded-[5px] px-2.5 py-1 border border-border/20 ${
inv.status === "PAID"
? "bg-emerald-500/10"
: inv.status === "PENDING"
? "bg-amber-500/10"
: inv.status === "DRAFT"
? "bg-secondary/50"
: "bg-red-500/10"
}`}
>
<Text
className={`text-[8px] font-black uppercase tracking-widest ${
inv.status === "PAID"
? "text-emerald-600"
: inv.status === "PENDING"
? "text-amber-600"
: inv.status === "DRAFT"
? "text-muted-foreground"
: "text-red-600"
}`}
>
{inv.status}
</Text>
</View>
</View>
<ChevronRight
size={14}
color="#94a3b8"
className="ml-2"
/>
</CardContent>
</Card>
</ShadowWrapper>
</Pressable>
))
) : (
<View className="py-6">
<EmptyState
title="No activity yet"
description="Payments and invoices you create will show up here so you can track everything in one place."
hint="Create a proforma invoice to generate your first activity."
actionLabel="Create Proforma"
onActionPress={() => nav.go("proforma/create")}
previewLines={4}
/>
</View>
)}
</View>
)}
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -1,281 +1,100 @@
import React, { useState, useEffect } from "react"; import { View, ScrollView } from 'react-native';
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native"; import { useLocalSearchParams, router } from 'expo-router';
import { useSirouRouter } from "@sirou/react-native"; import { Text } from '@/components/ui/text';
import { AppRoutes } from "@/lib/routes"; import { Button } from '@/components/ui/button';
import { Stack, useLocalSearchParams } from "expo-router"; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Text } from "@/components/ui/text"; import { FileText, Calendar, User, Share2, Download, ChevronRight } from '@/lib/icons';
import { Button } from "@/components/ui/button"; import { MOCK_INVOICES } from '@/lib/mock-data';
import { Card } from "@/components/ui/card";
import { const PRIMARY = '#ea580c';
FileText, const MOCK_ITEMS = [
Calendar, { description: 'Marketing Landing Page Package', qty: 1, unitPrice: 1000, total: 1000 },
Share2, { description: 'Instagram Post Initial Design', qty: 4, unitPrice: 100, total: 400 },
Download, ];
ArrowLeft,
ExternalLink,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api } from "@/lib/api";
import { toast } from "@/lib/toast-store";
export default function InvoiceDetailScreen() { export default function InvoiceDetailScreen() {
const nav = useSirouRouter<AppRoutes>(); const { id } = useLocalSearchParams<{ id: string }>();
const { id } = useLocalSearchParams(); const invoice = MOCK_INVOICES.find((i) => i.id === id);
const [loading, setLoading] = useState(true);
const [invoice, setInvoice] = useState<any>(null);
useEffect(() => {
fetchInvoice();
}, [id]);
const fetchInvoice = async () => {
try {
setLoading(true);
const data = await api.invoices.getById({ params: { id: id as string } });
setInvoice(data);
} catch (error: any) {
console.error("[InvoiceDetail] Error:", error);
toast.error("Error", "Failed to load invoice details");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice Details" showBack />
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" />
</View>
</ScreenWrapper>
);
}
if (!invoice) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice Details" showBack />
<View className="flex-1 justify-center items-center">
<Text variant="muted">Invoice not found</Text>
</View>
</ScreenWrapper>
);
}
return ( return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice Details" showBack />
<ScrollView <ScrollView
className="flex-1" className="flex-1 bg-[#f5f5f5]"
contentContainerStyle={{ padding: 16, paddingBottom: 120 }} contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Status Hero Card */} <Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
<Card className="mb-4 overflow-hidden rounded-[6px] border-0 bg-primary"> <CardContent className="p-5">
<View className="p-5"> <View className="flex-row items-center justify-between">
<View className="flex-row items-center justify-between mb-3"> <View className="flex-row items-center gap-2">
<View className="bg-white/20 p-1.5 rounded-[6px]"> <FileText color={PRIMARY} size={22} strokeWidth={2} />
<FileText color="white" size={16} strokeWidth={2.5} /> <Text className="font-semibold text-gray-900">Invoice #{invoice?.invoiceNumber ?? id}</Text>
</View> </View>
<View <View className="rounded-full bg-amber-500/20 px-2.5 py-1">
className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`} <Text className="text-xs font-medium text-amber-700">{invoice?.status ?? 'Waiting'}</Text>
>
<Text
className={`text-[10px] font-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
>
{invoice.status || "Pending"}
</Text>
</View> </View>
</View> </View>
<Text className="text-muted-foreground mt-2 text-sm">Amount due</Text>
<Text variant="small" className="text-white/70 mb-0.5"> <Text className="mt-1 text-2xl font-bold text-gray-900">${invoice?.amount.toLocaleString() ?? '—'}</Text>
Total Amount <View className="mt-3 flex-row gap-4">
</Text>
<Text variant="h3" className="text-white font-bold mb-3">
${Number(invoice.amount).toLocaleString()}
</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"> <View className="flex-row items-center gap-1.5">
<Calendar color="rgba(255,255,255,0.9)" size={12} /> <Calendar color="#71717a" size={16} strokeWidth={2} />
<Text className="text-white/90 text-xs font-semibold"> <Text className="text-muted-foreground text-sm">Due {invoice?.dueDate ?? '—'}</Text>
Due {new Date(invoice.dueDate).toLocaleDateString()}
</Text>
</View> </View>
<View className="h-3 w-[1px] bg-white/60" /> <View className="flex-row items-center gap-1.5">
<Text className="text-white/90 text-xs font-semibold"> <Calendar color="#71717a" size={16} strokeWidth={2} />
#{invoice.invoiceNumber || id} <Text className="text-muted-foreground text-sm">Issued {invoice?.createdAt ?? '—'}</Text>
</Text>
</View> </View>
</View> </View>
</CardContent>
</Card> </Card>
{/* Recipient & Category — inline info strip */} <Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
<Card className="bg-card rounded-[6px] mb-4"> <CardHeader className="pb-2">
<View className="flex-row px-4 py-2"> <View className="flex-row items-center gap-2">
<View className="flex-1 flex-row items-center"> <User color="#71717a" size={18} strokeWidth={2} />
<View className="flex-col"> <CardTitle className="text-base">Bill to</CardTitle>
<Text className="text-foreground text-xs opacity-60">
Recipient
</Text>
<Text
variant="p"
className="text-foreground font-semibold"
numberOfLines={1}
>
{invoice.customerName || "—"}
</Text>
</View>
</View>
<View className="w-[1px] bg-border/70 mx-3" />
<View className="flex-1 flex-row items-center">
<View className="flex-col">
<Text className="text-foreground text-xs opacity-60">
Category
</Text>
<Text
variant="p"
className="text-foreground font-semibold"
numberOfLines={1}
>
General
</Text>
</View>
</View>
</View> </View>
</CardHeader>
<CardContent>
<Text className="font-medium text-gray-900">{invoice?.recipient ?? '—'}</Text>
<Text className="text-muted-foreground text-sm">{invoice?.recipientEmail ?? '—'}</Text>
</CardContent>
</Card> </Card>
{/* Items / Billing Summary */} <Card className="mb-4 overflow-hidden rounded-xl border border-border bg-white">
<Card className="mb-4 bg-card rounded-[6px]"> <CardHeader className="pb-2">
<View className="p-4"> <CardTitle className="text-base">Items</CardTitle>
<View className="flex-row items-center gap-2 mb-2"> </CardHeader>
<Text <CardContent className="gap-2">
variant="small" {MOCK_ITEMS.map((item, i) => (
className="font-bold opacity-60 uppercase text-[10px] tracking-widest" <View key={i} className="flex-row justify-between border-b border-border py-2 last:border-0">
> <Text className="text-gray-700">{item.description} × {item.qty}</Text>
Billing Summary <Text className="font-medium text-gray-900">${item.total.toLocaleString()}</Text>
</Text>
</View>
<View className="flex-row justify-between py-3 border-b border-border/70">
<View className="flex-1 pr-4">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
>
Subtotal
</Text>
</View>
<Text variant="p" className="text-foreground font-bold text-sm">
$
{(
Number(invoice.amount) - (Number(invoice.taxAmount) || 0)
).toLocaleString()}
</Text>
</View>
{Number(invoice.taxAmount) > 0 && (
<View className="flex-row justify-between py-3 border-b border-border/70">
<View className="flex-1 pr-4">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
>
Tax
</Text>
</View>
<Text variant="p" className="text-foreground font-bold text-sm">
+ ${Number(invoice.taxAmount).toLocaleString()}
</Text>
</View>
)}
<View className="mt-3 pt-3 flex-row justify-between items-center border-t border-border/70">
<Text variant="muted" className="font-semibold text-sm">
Total Balance
</Text>
<Text
variant="h3"
className="text-foreground font-semibold text-xl tracking-tight"
>
${Number(invoice.amount).toLocaleString()}
</Text>
</View>
</View>
</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>
))}
<View className="mt-2 border-t border-border pt-3">
<View className="flex-row justify-between"> <View className="flex-row justify-between">
<Text className="text-[10px] text-muted-foreground uppercase font-bold tracking-tighter"> <Text className="font-semibold text-gray-900">Total</Text>
Last Updated <Text className="font-semibold text-gray-900">${invoice?.amount.toLocaleString() ?? '1,540'}</Text>
</Text>
<Text className="text-[10px] text-foreground font-bold">
{new Date(invoice.updatedAt).toLocaleString()}
</Text>
</View> </View>
</View> </View>
</CardContent>
</Card>
{/* Actions */}
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<Button <Button variant="outline" className="min-h-12 flex-1 rounded-xl border-border" onPress={() => {}}>
className=" flex-1 mb-4 h-12 rounded-[10px] bg-primary shadow-lg shadow-primary/20" <Share2 color={PRIMARY} size={20} strokeWidth={2} />
onPress={() => {}} <Text className="ml-2 font-medium text-gray-700">Share</Text>
>
<Share2 color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white text-[12px] font-black uppercase tracking-widest">
Share SMS
</Text>
</Button> </Button>
<ShadowWrapper> <Button variant="outline" className="min-h-12 flex-1 rounded-xl border-border" onPress={() => {}}>
<Button <Download color={PRIMARY} size={20} strokeWidth={2} />
className=" flex-1 mb-4 h-12 rounded-[10px] bg-card border border-border" <Text className="ml-2 font-medium text-gray-700">PDF</Text>
onPress={() => {}}
>
<Download color="#0f172a" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-foreground text-[12px] font-black uppercase tracking-widest">
Get PDF
</Text>
</Button> </Button>
</ShadowWrapper>
</View> </View>
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
<ChevronRight className="rotate-180" color="#71717a" size={20} strokeWidth={2} />
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
</Button>
</ScrollView> </ScrollView>
</ScreenWrapper>
); );
} }

View File

@ -1,288 +1,40 @@
import React, { useState } from "react"; import { View, ScrollView, Pressable } from 'react-native';
import { import { router } from 'expo-router';
View, import { Text } from '@/components/ui/text';
ScrollView, import { Button } from '@/components/ui/button';
Pressable, import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
TextInput, import { Mail, ArrowLeft } from '@/lib/icons';
StyleSheet,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Image,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Mail, Lock, ArrowRight, Eye, EyeOff, Chrome, User, Globe } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useAuthStore } from "@/lib/auth-store";
import * as Linking from "expo-linking";
import { api, BASE_URL, rbacApi } from "@/lib/api";
import { useColorScheme } from "nativewind";
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 setAuth = useAuthStore((state) => state.setAuth);
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const { language, setLanguage } = useLanguageStore();
const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
if (!identifier || !password) {
toast.error(
"Required Fields",
"Please enter both identifier and password",
);
return;
}
setLoading(true);
const isEmail = identifier.includes("@");
const payload = isEmail
? { email: identifier, password }
: { phone: identifier, password };
try {
// Using the new api.auth.login which is powered by simple-api
const response = await api.auth.login({ body: payload });
// Store user, access token, refresh token, and permissions
// // 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.");
// Explicitly navigate to home
nav.go("(tabs)");
} catch (err: any) {
toast.error("Login Failed", err.message || "Invalid credentials");
} finally {
setLoading(false);
}
};
const handleGoogleLogin = async () => {
try {
setLoading(true);
await GoogleSignin.hasPlayServices();
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.");
nav.go("(tabs)");
} catch (error: any) {
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(
"Google Login Failed",
error.message || "An error occurred",
);
}
} finally {
setLoading(false);
}
};
return ( return (
<ScreenWrapper>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1"
>
<ScrollView <ScrollView
className="flex-1" className="flex-1 bg-[#f5f5f5]"
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 10 }} contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}
> >
<View className="flex-row justify-end mb-4"> <Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
<Pressable <Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
onPress={() => setLanguageModalVisible(true)} <CardHeader>
className="p-2 rounded-full bg-card border border-border" <CardTitle className="text-lg">Sign in</CardTitle>
> <CardDescription className="mt-1">Use the same account as the web app.</CardDescription>
<Globe color={isDark ? "#f1f5f9" : "#0f172a"} size={20} /> </CardHeader>
</Pressable> <CardContent className="gap-3">
</View> <Button className="min-h-12 rounded-xl bg-primary">
<Mail color="#ffffff" size={20} strokeWidth={2} />
{/* Logo / Branding */} <Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
<View className="items-center mb-10">
<Text variant="h2" className="mt-6 font-bold text-foreground">
Login
</Text>
<Text variant="muted" className="mt-2 text-center">
Sign in to manage your tickets & invoices
</Text>
</View>
{/* Form */}
<View className="gap-5">
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Email or Phone Number
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<User size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="john@example.com or +251..."
placeholderTextColor={getPlaceholderColor(isDark)}
value={identifier}
onChangeText={setIdentifier}
autoCapitalize="none"
/>
</View>
</View>
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Password
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="••••••••"
placeholderTextColor={getPlaceholderColor(isDark)}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff size={18} color={isDark ? "#94a3b8" : "#64748b"} />
) : (
<Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} />
)}
</Pressable>
</View>
</View>
<Button
className="h-10 bg-primary rounded-[6px] shadow-lg shadow-primary/30 mt-2"
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<>
<Text className="text-white font-bold text-base mr-2">
Sign In
</Text>
<ArrowRight color="white" size={18} strokeWidth={2.5} />
</>
)}
</Button> </Button>
</View> <Button variant="outline" className="min-h-12 rounded-xl border-border">
<Text className="font-medium text-gray-700">Continue with Google</Text>
{/* Social / Other */} </Button>
<View className="mt-12"> </CardContent>
<View className="flex-row items-center mb-8"> </Card>
<View className="flex-1 h-[1px] bg-border" /> <Pressable onPress={() => router.push('/register')} className="mt-4">
<Text <Text className="text-center text-primary font-medium">Create account</Text>
variant="small"
className="mx-4 text-muted-foreground uppercase font-bold tracking-widest text-[10px]"
>
or
</Text>
<View className="flex-1 h-[1px] bg-border" />
</View>
<View className="flex-row gap-4">
<Pressable
onPress={handleGoogleLogin}
disabled={loading}
className="flex-1 h-10 border border-border rounded-[6px] items-center justify-center flex-row bg-card"
>
{loading ? (
<ActivityIndicator color={isDark ? "white" : "black"} />
) : (
<>
<Image
source={require("@/assets/google-logo.png")}
style={{ width: 22, height: 22 }}
resizeMode="contain"
/>
<Text className="ml-3 font-bold text-foreground text-base">
Continue with Google
</Text>
</>
)}
</Pressable> </Pressable>
</View> <Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
<ArrowLeft color="#71717a" size={20} strokeWidth={2} />
<Pressable <Text className="ml-2 font-medium text-muted-foreground">Back</Text>
className="mt-10 items-center justify-center py-2" </Button>
onPress={() => nav.go("register")}
>
<Text className="text-muted-foreground">
Don't have an account?{" "}
<Text className="text-primary">Create one</Text>
</Text>
</Pressable>
</View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView>
<LanguageModal
visible={languageModalVisible}
current={language}
onSelect={(lang) => setLanguage(lang)}
onClose={() => setLanguageModalVisible(false)}
/>
</ScreenWrapper>
); );
} }

View File

@ -1,134 +1,38 @@
import React, { useCallback, useEffect, useState } from "react"; import { View, ScrollView, Pressable } from 'react-native';
import { View, ActivityIndicator, FlatList, RefreshControl } from "react-native"; import { router } from 'expo-router';
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 { ScreenWrapper } from "@/components/ScreenWrapper"; import { Bell, Settings, ChevronRight } from '@/lib/icons';
import { StandardHeader } from "@/components/StandardHeader";
import { api } from "@/lib/api";
import { EmptyState } from "@/components/EmptyState";
type NotificationItem = { const MOCK_NOTIFICATIONS = [
id: string; { id: '1', title: 'Invoice reminder', body: 'Invoice #2 to Robin Murray is due in 2 days.', time: '2h ago', read: false },
title?: string; { id: '2', title: 'Payment received', body: 'Payment of $500 received for Invoice #4.', time: '1d ago', read: true },
body?: string; { id: '3', title: 'Proforma submission', body: 'Vendor A submitted a quote for Marketing Landing Page.', time: '2d ago', read: true },
message?: string; ];
createdAt?: string;
read?: boolean;
};
export default function NotificationsScreen() { export default function NotificationsScreen() {
const [items, setItems] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const fetchNotifications = useCallback(
async (pageNum: number, mode: "initial" | "refresh" | "more") => {
try {
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 ( return (
<Card className="mb-2"> <ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
<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={() => router.push('/notifications/settings')}>
<Settings color="#ea580c" size={18} strokeWidth={2} />
<Text className="text-primary font-medium">Settings</Text>
</Pressable>
</View>
{MOCK_NOTIFICATIONS.map((n) => (
<Card key={n.id} className={`mb-2 ${!n.read ? 'border-primary/30' : ''}`}>
<CardContent className="py-3"> <CardContent className="py-3">
<Text className="font-semibold text-foreground"> <Text className="font-semibold text-gray-900">{n.title}</Text>
{item.title ?? "Notification"} <Text className="text-muted-foreground mt-1 text-sm">{n.body}</Text>
</Text> <Text className="text-muted-foreground mt-1 text-xs">{n.time}</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,221 +1,41 @@
import React, { useCallback, useEffect, useState } from "react"; import { View, ScrollView, Switch } from 'react-native';
import { import { router } from 'expo-router';
View, import { useState } from 'react';
ScrollView, import { Text } from '@/components/ui/text';
Switch, import { Button } from '@/components/ui/button';
ActivityIndicator, import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
TextInput,
useColorScheme,
Pressable,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
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 [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"> <ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
<StandardHeader showBack title="Notification settings" /> <Card className="mb-4">
<CardHeader>
{loading ? ( <CardTitle>Notification settings</CardTitle>
<View className="flex-1 items-center justify-center"> </CardHeader>
<ActivityIndicator /> <CardContent className="gap-4">
<View className="flex-row items-center justify-between">
<Text className="text-gray-900">Invoice reminders</Text>
<Switch value={invoiceReminders} onValueChange={setInvoiceReminders} />
</View> </View>
) : ( <View className="flex-row items-center justify-between">
<ScrollView <Text className="text-gray-900">News & announcements</Text>
contentContainerStyle={{ padding: 16, paddingBottom: 110 }} <Switch value={newsAlerts} onValueChange={setNewsAlerts} />
showsVerticalScrollIndicator={false}
>
<View className="mb-5">
<Text variant="muted" className="text-xs font-semibold mb-2 px-1">
Preferences
</Text>
<Card className="overflow-hidden">
<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>
<View className="flex-1"> <View className="flex-row items-center justify-between">
<Text className="text-foreground font-medium"> <Text className="text-gray-900">Report ready</Text>
Invoice reminders <Switch value={reportReady} onValueChange={setReportReady} />
</Text>
<Text variant="muted" className="text-xs mt-0.5">
Get reminders before invoices are due
</Text>
</View>
<Switch
value={invoiceReminders}
onValueChange={setInvoiceReminders}
trackColor={{ false: "#94a3b8", true: "#ea580c" }}
thumbColor="#ffffff"
/>
</View>
<View className="px-4 py-3 border-b border-border/40">
<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 className="flex-1">
<Text className="text-foreground font-medium">
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>
</ScrollView>
)}
<View className="absolute bottom-0 pb-10 left-0 right-0 p-4 bg-background border-t border-border"> <Button variant="outline" onPress={() => router.back()}>
<Button className="bg-primary" onPress={onSave} disabled={saving || loading}> <Text className="font-medium">Back</Text>
<Text className="text-white font-semibold">
{saving ? "Saving..." : "Save"}
</Text>
</Button> </Button>
</View> </ScrollView>
<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

@ -1,795 +0,0 @@
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>
);
}

View File

@ -1,131 +1,44 @@
import { View, ScrollView, Pressable } from "react-native"; import { View, ScrollView } from 'react-native';
import { useSirouRouter, useSirouParams } from "@sirou/react-native"; import { useLocalSearchParams, router } from 'expo-router';
import { AppRoutes } from "@/lib/routes"; import { Text } from '@/components/ui/text';
import { Stack } from "expo-router"; import { Button } from '@/components/ui/button';
import { Text } from "@/components/ui/text"; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
export default function PaymentDetailScreen() { export default function PaymentDetailScreen() {
const nav = useSirouRouter<AppRoutes>(); const { id } = useLocalSearchParams<{ id: string }>();
const { id } = useSirouParams<AppRoutes, "payments/[id]">();
return ( return (
<ScreenWrapper className="bg-background"> <ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
<Stack.Screen options={{ headerShown: false }} /> <Card className="mb-4">
<CardHeader>
<View className="px-6 pt-4 flex-row justify-between items-center"> <CardTitle>Payment #{id ?? '—'}</CardTitle>
<Pressable </CardHeader>
onPress={() => nav.back()} <CardContent className="gap-2">
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" <View className="flex-row justify-between py-2">
> <Text className="text-muted-foreground">Amount</Text>
<ArrowLeft color="#0f172a" size={20} /> <Text className="font-semibold text-gray-900">$2,000.00</Text>
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
Payment Match
</Text>
<View className="w-9" />
</View> </View>
<View className="flex-row justify-between py-2">
<ScrollView <Text className="text-muted-foreground">Source</Text>
className="flex-1" <Text className="text-gray-900">Telebirr</Text>
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
>
<Card className=" overflow-hidden rounded-[6px] border-0 bg-primary">
<View className="p-5">
<View className="flex-row items-center justify-between mb-3">
<View className="bg-white/20 p-1.5 rounded-[6px]">
<Wallet color="white" size={18} strokeWidth={2.5} />
</View> </View>
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10"> <View className="flex-row justify-between py-2">
<Text className={`text-[10px] font-bold text-white`}> <Text className="text-muted-foreground">Date</Text>
Pending Match <Text className="text-gray-900">Sep 11, 2022</Text>
</Text>
</View>
</View>
<Text variant="small" className="text-white/70 mb-0.5">
Received Amount
</Text>
<Text variant="h3" className="text-white font-bold mb-3">
$2,000.00
</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">
<Text className="text-white/90 text-xs font-semibold">
TXN-9982734
</Text>
</View>
<View className="h-3 w-[1px] bg-white/60" />
<Text className="text-white/90 text-xs font-semibold">
Telebirr SMS
</Text>
</View> </View>
<View className="flex-row justify-between py-2">
<Text className="text-muted-foreground">Associated invoice</Text>
<Text className="text-amber-600">Not linked</Text>
</View> </View>
</CardContent>
</Card> </Card>
{/* Transaction Details */} <Button className="mb-3 bg-primary" onPress={() => {}}>
<Text className="text-primary-foreground font-medium">Associate to invoice</Text>
<Text variant="h4" className="text-foreground mt-4 mb-2"> </Button>
Transaction Details <Button variant="outline" onPress={() => router.back()}>
</Text> <Text className="font-medium">Back to payments</Text>
<Card className="bg-card rounded-[6px] mb-3">
<View className="p-4">
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-2">
<Clock color="#000" size={13} />
<Text variant="muted" className="text-sm">
Received On
</Text>
</View>
<Text variant="p" className="text-foreground text-sm">
Sep 11, 2022 · 14:30
</Text>
</View>
<View className="h-[1px] bg-border/70 my-3" />
<View className="flex-row items-center justify-between py-1">
<View className="flex-row items-center gap-2">
<Link2 color="#000" size={13} />
<Text variant="muted" className="text-sm">
Status
</Text>
</View>
<View className="bg-amber-500/10 px-2.5 py-1 rounded-[4px]">
<Text className="text-amber-600 text-xs font-semibold">
Awaiting Link
</Text>
</View>
</View>
</View>
</Card>
{/* SMS Message */}
<Card className="bg-card rounded-[6px] mb-6">
<View className="p-4">
<Text variant="muted" className="mb-3 font-semibold">
Original SMS
</Text>
<Text className="text-foreground/70 font-medium leading-6 text-sm">
"Payment received from Elnatan Jansen for order #2322 via
Telebirr. Amount: $2,000. Ref: B88-22X7."
</Text>
</View>
</Card>
{/* Action */}
<Button className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30">
<Link2 color="white" size={18} strokeWidth={2.5} />
<Text className=" text-white text-xs font-semibold uppercase tracking-widest">
Associate to Invoice
</Text>
</Button> </Button>
</ScrollView> </ScrollView>
</ScreenWrapper>
); );
} }

View File

@ -1,81 +0,0 @@
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

@ -1,362 +0,0 @@
import React, { useState } from "react";
import {
View,
ScrollView,
Pressable,
Image,
Switch,
InteractionManager,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text";
import {
ArrowLeft,
Settings,
ChevronRight,
CreditCard,
ShieldCheck,
FileText,
HelpCircle,
History,
Bell,
LogOut,
User,
Lock,
Globe,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { useColorScheme } from "nativewind";
import { saveTheme, AppTheme } from "@/lib/theme";
import { useAuthStore } from "@/lib/auth-store";
import { useLanguageStore, AppLanguage } from "@/lib/language-store";
import { LanguageModal } from "@/components/LanguageModal";
import { ThemeModal } from "@/components/ThemeModal";
// ── Constants ─────────────────────────────────────────────────────
const AVATAR_FALLBACK_BASE =
"https://ui-avatars.com/api/?background=ea580c&color=fff&name=";
// ── Theme bottom sheet ────────────────────────────────────────────
const THEME_OPTIONS = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System Default" },
] as const;
type ThemeOption = (typeof THEME_OPTIONS)[number]["value"];
const LANGUAGE_OPTIONS = [
{ value: "en", label: "English" },
{ value: "am", label: "Amharic" },
] as const;
type LanguageOption = (typeof LANGUAGE_OPTIONS)[number]["value"];
// ── Shared menu components ────────────────────────────────────────
function MenuGroup({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<View className="mb-5">
<Text variant="muted" className="text-xs font-semibold mb-2 px-1">
{label}
</Text>
<ShadowWrapper>
<View className="bg-card rounded-[10px] overflow-hidden">
{children}
</View>
</ShadowWrapper>
</View>
);
}
function MenuItem({
icon,
label,
sublabel,
onPress,
right,
destructive = false,
isLast = false,
}: {
icon: React.ReactNode;
label: string;
sublabel?: string;
onPress?: () => void;
right?: React.ReactNode;
destructive?: boolean;
isLast?: boolean;
}) {
return (
<Pressable
onPress={onPress}
className={`flex-row items-center px-4 py-3 ${
!isLast ? "border-b border-border/40" : ""
}`}
>
<View className="h-9 w-9 rounded-[8px] items-center justify-center mr-3">
{icon}
</View>
<View className="flex-1 mt-[-10px]">
<Text
variant="p"
className={`font-medium ${destructive ? "text-red-500" : "text-foreground"}`}
>
{label}
</Text>
{sublabel ? (
<Text variant="muted" className="text-xs mt-0.5">
{sublabel}
</Text>
) : null}
</View>
{right !== undefined ? (
right
) : (
<ChevronRight color={destructive ? "#ef4444" : "#94a3b8"} size={18} />
)}
</Pressable>
);
}
// ── Screen ────────────────────────────────────────────────────────
export default function ProfileScreen() {
const nav = useSirouRouter<AppRoutes>();
const { user, logout } = useAuthStore();
const { setColorScheme, colorScheme } = useColorScheme();
const { language, setLanguage } = useLanguageStore();
const [notifications, setNotifications] = useState(true);
const [themeSheetVisible, setThemeSheetVisible] = useState(false);
const [languageSheetVisible, setLanguageSheetVisible] = useState(false);
const currentTheme: ThemeOption = (colorScheme as ThemeOption) ?? "system";
const currentLanguage: LanguageOption = (language as LanguageOption) ?? "en";
const handleThemeSelect = (val: AppTheme) => {
// NativeWind 4 handles system/light/dark
setColorScheme(val);
saveTheme(val); // persist across restarts
};
return (
<ScreenWrapper className="bg-background">
{/* Header */}
<View className="px-6 pt-4 flex-row justify-between items-center">
<Pressable
onPress={() => nav.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<ArrowLeft
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={20}
/>
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
Profile
</Text>
{/* Edit Profile shortcut */}
<Pressable
onPress={() => nav.go("edit-profile")}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<User
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={18}
/>
</Pressable>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 24,
paddingBottom: 80,
}}
>
{/* Avatar */}
<View className="items-center mb-8">
<View className="h-20 w-20 rounded-full overflow-hidden bg-muted mb-3">
<Image
source={{
uri:
user?.avatar ||
`${AVATAR_FALLBACK_BASE}${encodeURIComponent(`${user?.firstName} ${user?.lastName}`)}`,
}}
className="h-full w-full"
/>
</View>
<Text variant="h4" className="text-foreground">
{user?.firstName} {user?.lastName}
</Text>
<Text variant="muted" className="text-sm mt-0.5">
{user?.email}
</Text>
</View>
<MenuGroup label="Account">
{/* <MenuItem
icon={
<CreditCard
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={17}
/>
}
label="Subscription"
sublabel="Pro Plan — active"
onPress={() => {}}
/> */}
<MenuItem
icon={
<History
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={17}
/>
}
label="Transaction History"
onPress={() => nav.go("history")}
isLast
/>
</MenuGroup>
{/* Preferences */}
<MenuGroup label="Preferences">
{/* <MenuItem
icon={
<Bell
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={17}
/>
}
label="Push Notifications"
right={
<Switch
value={notifications}
onValueChange={setNotifications}
trackColor={{ true: "#ea580c" }}
/>
}
/> */}
<MenuItem
icon={
<Settings
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={17}
/>
}
label="Appearance"
sublabel={
THEME_OPTIONS.find((o) => o.value === currentTheme)?.label ??
"System Default"
}
onPress={() => setThemeSheetVisible(true)}
/>
<MenuItem
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"
sublabel="PIN & Biometrics"
onPress={() => {}}
isLast
/>
</MenuGroup>
{/* Support & Legal */}
<MenuGroup label="Support & Legal">
<MenuItem
icon={
<HelpCircle
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={17}
/>
}
label="Help & Support"
onPress={() => nav.go("help")}
/>
<MenuItem
icon={
<ShieldCheck
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={17}
/>
}
label="Privacy Policy"
onPress={() => nav.go("privacy")}
/>
<MenuItem
icon={
<FileText
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={17}
/>
}
label="Terms of Use"
onPress={() => nav.go("terms")}
isLast
/>
</MenuGroup>
{/* Logout */}
<ShadowWrapper>
<View className="bg-card rounded-[10px] overflow-hidden">
<MenuItem
icon={
<LogOut
color="#ef4444"
size={17}
/>
}
label="Log Out"
destructive
onPress={async () => {
await logout();
nav.go("login");
}}
right={null}
isLast
/>
</View>
</ShadowWrapper>
</ScrollView>
{/* Theme sheet */}
<ThemeModal
visible={themeSheetVisible}
current={currentTheme}
onSelect={(theme) => handleThemeSelect(theme)}
onClose={() => setThemeSheetVisible(false)}
/>
<LanguageModal
visible={languageSheetVisible}
current={currentLanguage}
onSelect={(lang) => setLanguage(lang)}
onClose={() => setLanguageSheetVisible(false)}
/>
</ScreenWrapper>
);
}

View File

@ -1,329 +1,66 @@
import React, { useState, useEffect } from "react"; import { View, ScrollView } from 'react-native';
import { View, ScrollView, Pressable, ActivityIndicator } from "react-native"; import { useLocalSearchParams, router } from 'expo-router';
import { useSirouRouter } from "@sirou/react-native"; import { Text } from '@/components/ui/text';
import { AppRoutes } from "@/lib/routes"; import { Button } from '@/components/ui/button';
import { Stack, useLocalSearchParams } from "expo-router"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { useRouter } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
ArrowLeft,
DraftingCompass,
Clock,
Send,
ExternalLink,
ChevronRight,
CheckCircle2,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api } from "@/lib/api";
import { toast } from "@/lib/toast-store";
const dummyData = { const MOCK_ITEMS = [
id: "dummy-1", { description: 'Marketing Landing Page Package', qty: 1, unitPrice: 1000, total: 1000 },
proformaNumber: "PF-001", { description: 'Instagram Post Initial Design', qty: 4, unitPrice: 100, total: 400 },
customerName: "John Doe", ];
customerEmail: "john@example.com", const MOCK_SUBTOTAL = 1400;
customerPhone: "+1234567890", const MOCK_TAX = 140;
amount: { value: 1000, currency: "USD" }, const MOCK_TOTAL = 1540;
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 { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { id } = useLocalSearchParams();
const [loading, setLoading] = useState(true);
const [proforma, setProforma] = useState<any>(null);
useEffect(() => {
fetchProforma();
}, [id]);
const fetchProforma = async () => {
try {
setLoading(true);
const data = await api.proforma.getById({ params: { id: id as string } });
setProforma(data);
} catch (error: any) {
console.error("[ProformaDetail] Error:", error);
toast.error("Error", "Failed to load proforma details");
setProforma(dummyData); // Use dummy data for testing
} finally {
setLoading(false);
}
};
if (loading) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Proforma" showBack />
<View className="flex-1 justify-center items-center">
<ActivityIndicator color="#ea580c" size="large" />
</View>
</ScreenWrapper>
);
}
if (!proforma) {
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Proforma" showBack />
<View className="flex-1 justify-center items-center">
<Text variant="muted">Proforma not found</Text>
</View>
</ScreenWrapper>
);
}
const subtotal =
proforma.items?.reduce(
(acc: number, item: any) => acc + (Number(item.total) || 0),
0,
) || 0;
return ( return (
<ScreenWrapper className="bg-background"> <ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}>
<Stack.Screen options={{ headerShown: false }} /> <Card className="mb-4">
<CardHeader>
{/* Header */} <CardTitle>Proforma Request #{id ?? '—'}</CardTitle>
<StandardHeader title="Proforma" showBack /> <CardDescription>Marketing Landing Page Package</CardDescription>
<Text className="text-muted-foreground mt-1 text-sm">Deadline: Sep 20, 2022 · OPEN</Text>
<ScrollView </CardHeader>
className="flex-1" <CardContent className="gap-2">
contentContainerStyle={{ padding: 16, paddingBottom: 120 }} {MOCK_ITEMS.map((item, i) => (
showsVerticalScrollIndicator={false} <View key={i} className="flex-row justify-between py-2">
> <Text className="text-gray-700">{item.description} × {item.qty}</Text>
{/* Proforma Info Card */} <Text className="font-medium text-gray-900">${item.total.toLocaleString()}</Text>
<Card className="bg-card rounded-[12px] mb-4 border border-border">
<View className="p-4">
<View className="flex-row items-center gap-3 mb-3">
<View className="bg-primary/10 p-2 rounded-[8px]">
<DraftingCompass color="#ea580c" size={16} strokeWidth={2.5} />
</View>
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
Proforma Details
</Text>
</View>
<View className="gap-2">
<View className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Proforma Number</Text>
<Text className="text-foreground font-semibold text-sm">{proforma.proformaNumber}</Text>
</View>
<View className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Issued Date</Text>
<Text className="text-foreground font-semibold text-sm">{new Date(proforma.issueDate).toLocaleDateString()}</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>
</Card>
{/* Customer Info Card */}
<Card className="bg-card rounded-[12px] mb-4 border border-border">
<View className="p-4">
<View className="flex-row items-center gap-3 mb-3">
<View className="bg-primary/10 p-2 rounded-[8px]">
<CheckCircle2 color="#ea580c" size={16} strokeWidth={2.5} />
</View>
<Text className="text-foreground font-bold text-sm uppercase tracking-widest">
Customer Information
</Text>
</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 className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Email</Text>
<Text className="text-foreground font-semibold text-sm">{proforma.customerEmail || "N/A"}</Text>
</View>
<View className="flex-row justify-between">
<Text variant="muted" className="text-xs font-medium">Phone</Text>
<Text className="text-foreground font-semibold text-sm">{proforma.customerPhone || "N/A"}</Text>
</View>
</View>
</View>
</Card>
{/* Line Items Card */}
<Card className="bg-card rounded-[6px] mb-4">
<View className="p-4">
<View className="flex-row items-center gap-2 mb-2">
<Text
variant="small"
className="font-bold uppercase tracking-widest text-[10px] opacity-60"
>
Line Items
</Text>
</View>
{proforma.items?.map((item: any, i: number) => (
<View
key={item.id || i}
className={`flex-row justify-between py-3 ${i < proforma.items.length - 1 ? "border-b border-border/40" : ""}`}
>
<View className="flex-1 pr-4">
<Text
variant="p"
className="text-foreground font-semibold text-sm"
>
{item.description}
</Text>
<Text variant="muted" className="text-[10px] mt-0.5">
{item.quantity} × {proforma.currency}{" "}
{Number(item.unitPrice).toLocaleString()}
</Text>
</View>
<Text variant="p" className="text-foreground font-bold text-sm">
{proforma.currency} {Number(item.total).toLocaleString()}
</Text>
</View> </View>
))} ))}
<View className="mt-2 border-t border-border pt-2">
<View className="mt-3 pt-3 border-t border-border/40 gap-2">
<View className="flex-row justify-between"> <View className="flex-row justify-between">
<Text <Text className="text-muted-foreground">Subtotal</Text>
variant="p" <Text className="text-gray-900">${MOCK_SUBTOTAL.toLocaleString()}</Text>
className="text-foreground font-semibold text-sm"
>
Subtotal
</Text>
<Text variant="p" className="text-foreground font-bold text-sm">
{proforma.currency} {subtotal.toLocaleString()}
</Text>
</View> </View>
{Number(proforma.taxAmount) > 0 && (
<View className="flex-row justify-between"> <View className="flex-row justify-between">
<Text <Text className="text-muted-foreground">Tax (10%)</Text>
variant="p" <Text className="text-gray-900">${MOCK_TAX.toLocaleString()}</Text>
className="text-foreground font-semibold text-sm"
>
Tax
</Text>
<Text
variant="p"
className="text-foreground font-bold text-sm"
>
{proforma.currency}{" "}
{Number(proforma.taxAmount).toLocaleString()}
</Text>
</View> </View>
)}
{Number(proforma.discountAmount) > 0 && (
<View className="flex-row justify-between"> <View className="flex-row justify-between">
<Text <Text className="font-semibold text-gray-900">Total</Text>
variant="p" <Text className="font-semibold text-gray-900">${MOCK_TOTAL.toLocaleString()}</Text>
className="text-red-500 font-semibold text-sm"
>
Discount
</Text>
<Text variant="p" className="text-red-500 font-bold text-sm">
-{proforma.currency}{" "}
{Number(proforma.discountAmount).toLocaleString()}
</Text>
</View>
)}
<View className="flex-row justify-between items-center mt-1">
<Text variant="p" className="text-foreground font-bold">
Total Amount
</Text>
<Text
variant="h4"
className="text-foreground font-bold tracking-tight"
>
{proforma.currency} {Number(proforma.amount).toLocaleString()}
</Text>
</View>
</View> </View>
</View> </View>
</CardContent>
</Card> </Card>
{/* Notes Section (New) */} <Button className="mb-3 bg-primary" onPress={() => {}}>
{proforma.notes && ( <Text className="text-primary-foreground font-medium">Send to contacts</Text>
<Card className="bg-card rounded-[6px] mb-4"> </Button>
<View className="p-4"> <Button variant="outline" onPress={() => router.back()}>
<Text <Text className="font-medium">Back to list</Text>
variant="small" </Button>
className="font-bold uppercase tracking-widest text-[10px] opacity-60 mb-2"
> <Text className="text-muted-foreground mt-6 mb-2 text-sm">Submissions (mock)</Text>
Additional Notes <Card>
</Text> <CardContent className="py-3">
<Text <Text className="font-medium text-gray-900">Vendor A $1,450</Text>
variant="p" <Text className="text-muted-foreground text-sm">Submitted Sep 15, 2022</Text>
className="text-foreground font-medium text-xs leading-5" </CardContent>
>
{proforma.notes}
</Text>
</View>
</Card> </Card>
)}
{/* Actions */}
<View className="gap-3">
<Button
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={() => {}}
>
<Send color="#ffffff" size={16} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black text-[12px] uppercase tracking-widest">
Share SMS
</Text>
</Button>
</View>
</ScrollView> </ScrollView>
</ScreenWrapper>
); );
} }

View File

@ -1,622 +0,0 @@
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 } from "expo-router";
import { colorScheme, useColorScheme } from "nativewind";
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();
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={getPlaceholderColor(isDark)}
value={value}
onChangeText={onChangeText}
keyboardType={numeric ? "numeric" : "default"}
autoCorrect={false}
autoCapitalize="none"
returnKeyType="next"
/>
</View>
);
}
const CURRENCIES = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
export default function CreateProformaScreen() {
const nav = useSirouRouter<AppRoutes>();
const [loading, setLoading] = useState(false);
// Fields
const [proformaNumber, setProformaNumber] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [description, setDescription] = useState("");
const [currency, setCurrency] = useState("USD");
const [taxAmount, setTaxAmount] = useState("0");
const [discountAmount, setDiscountAmount] = useState("0");
const [notes, setNotes] = useState("");
// Dates
const [issueDate, setIssueDate] = useState(
new Date().toISOString().split("T")[0],
);
const [dueDate, setDueDate] = useState("");
const [items, setItems] = useState<Item[]>([
{ id: 1, description: "", qty: "1", price: "" },
]);
const c = useInputColors();
// Modal States
const [showCurrency, setShowCurrency] = useState(false);
const [showIssueDate, setShowIssueDate] = useState(false);
const [showDueDate, setShowDueDate] = useState(false);
// Auto-generate Proforma Number and set default dates on mount
useEffect(() => {
const year = new Date().getFullYear();
const random = Math.floor(1000 + Math.random() * 9000);
setProformaNumber(`PROF-${year}-${random}`);
// Default Due Date: 30 days from now
const d = new Date();
d.setDate(d.getDate() + 30);
setDueDate(d.toISOString().split("T")[0]);
}, []);
const updateField = (id: number, field: keyof Item, value: string) =>
setItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)),
);
const addItem = () =>
setItems((prev) => [
...prev,
{ id: Date.now(), description: "", qty: "1", price: "" },
]);
const removeItem = (id: number) => {
if (items.length > 1)
setItems((prev) => prev.filter((item) => item.id !== id));
};
const subtotal = items.reduce(
(sum, item) =>
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
0,
);
const total =
subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0);
const handleSubmit = async () => {
if (!customerName) {
toast.error("Validation Error", "Please enter a customer name");
return;
}
try {
setLoading(true);
// Handle Phone Formatting (Auto-prepend +251 if needed)
const formattedPhone = customerPhone.startsWith("+")
? customerPhone
: customerPhone.length > 0
? `+251${customerPhone}`
: "";
const payload = {
proformaNumber,
customerName,
customerEmail,
customerPhone: formattedPhone,
amount: Number(total.toFixed(2)),
currency,
issueDate: new Date(issueDate).toISOString(),
dueDate: new Date(dueDate).toISOString(),
description: description || `Proforma for ${customerName}`,
notes,
taxAmount: parseFloat(taxAmount) || 0,
discountAmount: parseFloat(discountAmount) || 0,
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),
),
})),
};
await api.proforma.create({ body: payload });
toast.success("Success", "Proforma created successfully!");
nav.back();
} catch (err: any) {
console.error("[ProformaCreate] Error:", err);
toast.error("Error", err.message || "Failed to create proforma");
} finally {
setLoading(false);
}
};
const isDark = colorScheme.get() === "dark";
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Create Proforma" showBack />
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 30 }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Header Info */}
<Label>General Information</Label>
<ShadowWrapper>
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
<Field
label="Proforma Number"
value={proformaNumber}
onChangeText={setProformaNumber}
placeholder="e.g. PROF-2024-001"
/>
<Field
label="Project Description"
value={description}
onChangeText={setDescription}
placeholder="e.g. Web Development Services"
/>
</View>
</ShadowWrapper>
{/* Recipient */}
<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 Corp"
/>
<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>
</View>
</ShadowWrapper>
{/* Schedule */}
<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-[2]" />
</View>
</View>
</ShadowWrapper>
{/* Items */}
<View className="flex-row items-center justify-between mb-3">
<Label noMargin>Billable 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. UI Design"
value={item.description}
onChangeText={(v) => updateField(item.id, "description", v)}
/>
<View className="flex-row gap-3 mt-4">
<Field
label="Qty"
placeholder="1"
numeric
center
value={item.qty}
onChangeText={(v) => updateField(item.id, "qty", v)}
flex={1}
/>
<Field
label="Price"
placeholder="0.00"
numeric
value={item.price}
onChangeText={(v) => updateField(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>
{/* Summary */}
<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>
{/* Notes */}
<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 due within 30 days"
placeholderTextColor={getPlaceholderColor(isDark)}
value={notes}
onChangeText={setNotes}
multiline
/>
</View>
</ShadowWrapper>
{/* Footer */}
<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}{" "}
{total.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={loading}
>
<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={loading}
>
{loading ? (
<ActivityIndicator color="white" size="small" />
) : (
<>
<Send color="white" size={14} strokeWidth={2.5} />
<Text className="text-white font-bold text-sm ">
Create Proforma
</Text>
</>
)}
</Button>
</View>
</View>
</ScrollView>
{/* Currency Modal */}
<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>
{/* Issue Date Modal */}
<PickerModal
visible={showIssueDate}
onClose={() => setShowIssueDate(false)}
title="Select Issue Date"
>
<CalendarGrid
selectedDate={issueDate}
onSelect={(v) => {
setIssueDate(v);
setShowIssueDate(false);
}}
/>
</PickerModal>
{/* Due Date Modal */}
<PickerModal
visible={showDueDate}
onClose={() => setShowDueDate(false)}
title="Select Due Date"
>
<CalendarGrid
selectedDate={dueDate}
onSelect={(v) => {
setDueDate(v);
setShowDueDate(false);
}}
/>
</PickerModal>
</ScreenWrapper>
);
}
function Label({
children,
noMargin,
}: {
children: string;
noMargin?: boolean;
}) {
return (
<Text
variant="small"
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3"}`}
>
{children}
</Text>
);
}

View File

@ -1,584 +0,0 @@
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

@ -1,251 +1,40 @@
import React, { useState } from "react"; import { View, ScrollView, Pressable } from 'react-native';
import { import { router } from 'expo-router';
View, import { Text } from '@/components/ui/text';
ScrollView, import { Button } from '@/components/ui/button';
Pressable, import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
TextInput, import { Mail, ArrowLeft, UserPlus } from '@/lib/icons';
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import {
Mail,
Lock,
User,
Phone,
ArrowLeft,
ArrowRight,
TrianglePlanets,
Eye,
EyeOff,
Chrome,
Globe,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useAuthStore } from "@/lib/auth-store";
import { api } from "@/lib/api";
import { useColorScheme } from "nativewind";
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 setAuth = useAuthStore((state) => state.setAuth);
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const { language, setLanguage } = useLanguageStore();
const [languageModalVisible, setLanguageModalVisible] = useState(false);
const [form, setForm] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
password: "",
});
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const handleRegister = async () => {
const { firstName, lastName, email, phone, password } = form;
if (!firstName || !lastName || !email || !phone || !password) {
toast.error("Required Fields", "Please fill in all fields");
return;
}
setLoading(true);
try {
// Prepend +251 to the phone number for the API
const formattedPhone = `+251${phone}`;
const response = await api.auth.register({
body: {
...form,
phone: formattedPhone,
role: "VIEWER",
},
});
// Store user, access token, and refresh token
setAuth(response.user, response.accessToken, response.refreshToken);
toast.success("Account Created!", "Welcome to Yaltopia.");
nav.go("(tabs)");
} catch (err: any) {
toast.error(
"Registration Failed",
err.message || "Failed to create account",
);
} finally {
setLoading(false);
}
};
const updateForm = (key: keyof typeof form, val: string) =>
setForm((prev) => ({ ...prev, [key]: val }));
return ( return (
<ScreenWrapper className="bg-background">
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1"
>
<ScrollView <ScrollView
className="flex-1" className="flex-1 bg-[#f5f5f5]"
contentContainerStyle={{ paddingHorizontal:16 , paddingBottom: 10 }} contentContainerStyle={{ padding: 24, paddingVertical: 48 }}
keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}
> >
<View className="flex-row justify-end mt-4"> <Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text>
<Pressable <Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white">
onPress={() => setLanguageModalVisible(true)} <CardHeader>
className="p-2 rounded-full bg-card border border-border" <CardTitle className="text-lg">Create account</CardTitle>
> <CardDescription className="mt-1">Register with the same account format as the web app.</CardDescription>
<Globe color={isDark ? "#f1f5f9" : "#0f172a"} size={20} /> </CardHeader>
</Pressable> <CardContent className="gap-3">
</View> <Button className="min-h-12 rounded-xl bg-primary">
<UserPlus color="#ffffff" size={20} strokeWidth={2} />
<View className="items-center mb-10"> <Text className="ml-2 text-primary-foreground font-medium">Email & password</Text>
<Text
variant="h2"
className="mt-6 font-bold text-foreground text-center"
>
Create Account
</Text>
<Text variant="muted" className="mt-2 text-center">
Join Yaltopia and start managing your business
</Text>
</View>
<View className="gap-5">
<View className="flex-row gap-4">
<View className="flex-1">
<Text variant="small" className="font-semibold mb-2 ml-1">
First Name
</Text>
<View className="rounded-xl px-4 border border-border h-12 justify-center">
<TextInput
className="text-foreground"
placeholder="John"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.firstName}
onChangeText={(v) => updateForm("firstName", v)}
/>
</View>
</View>
<View className="flex-1">
<Text variant="small" className="font-semibold mb-2 ml-1">
Last Name
</Text>
<View className="rounded-xl px-4 border border-border h-12 justify-center">
<TextInput
className="text-foreground"
placeholder="Doe"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.lastName}
onChangeText={(v) => updateForm("lastName", v)}
/>
</View>
</View>
</View>
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Email Address
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="john@example.com"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.email}
onChangeText={(v) => updateForm("email", v)}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
</View>
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Phone Number
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<View className="flex-row items-center flex-1 ml-3">
<Text className="text-foreground text-sm font-medium">
+251{" "}
</Text>
<TextInput
className="flex-1 text-foreground"
placeholder="911 234 567"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.phone}
onChangeText={(v) => updateForm("phone", v)}
keyboardType="phone-pad"
/>
</View>
</View>
</View>
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Password
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="••••••••"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.password}
onChangeText={(v) => updateForm("password", v)}
secureTextEntry
/>
</View>
</View>
<Button
className="h-10 bg-primary rounded-[10px] shadow-lg shadow-primary/30 mt-4"
onPress={handleRegister}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<>
<Text className="text-white font-bold text-base mr-2">
Create Account
</Text>
<ArrowRight color="white" size={18} strokeWidth={2.5} />
</>
)}
</Button> </Button>
</View> <Button variant="outline" className="min-h-12 rounded-xl border-border">
<Text className="font-medium text-gray-700">Continue with Google</Text>
<Pressable </Button>
className="mt-10 items-center justify-center py-2" </CardContent>
onPress={() => nav.go("login")} </Card>
> <Pressable onPress={() => router.push('/login')} className="mt-2">
<Text className="text-muted-foreground"> <Text className="text-center text-primary font-medium">Already have an account? Sign in</Text>
Already have an account? <Text className="text-primary">Sign In</Text>
</Text>
</Pressable> </Pressable>
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}>
<ArrowLeft color="#71717a" size={20} strokeWidth={2} />
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
</Button>
</ScrollView> </ScrollView>
</KeyboardAvoidingView>
<LanguageModal
visible={languageModalVisible}
current={language}
onSelect={(lang) => setLanguage(lang)}
onClose={() => setLanguageModalVisible(false)}
/>
</ScreenWrapper>
); );
} }

View File

@ -1,16 +1,14 @@
import { View, ScrollView, Pressable } from "react-native"; import { View, ScrollView, Pressable } from 'react-native';
import { useSirouRouter } from "@sirou/react-native"; import { router } from 'expo-router';
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 { Card, CardContent } from '@/components/ui/card';
import { Card, CardContent } from "@/components/ui/card"; import { FileText, Download, ChevronRight, BarChart3 } from '@/lib/icons';
import { FileText, Download, ChevronRight, BarChart3 } from "@/lib/icons"; import { MOCK_REPORTS } from '@/lib/mock-data';
import { MOCK_REPORTS } from "@/lib/mock-data";
const PRIMARY = "#ea580c"; const PRIMARY = '#ea580c';
export default function ReportsScreen() { export default function ReportsScreen() {
const nav = useSirouRouter<AppRoutes>();
return ( return (
<ScrollView <ScrollView
className="flex-1 bg-[#f5f5f5]" className="flex-1 bg-[#f5f5f5]"
@ -26,10 +24,7 @@ export default function ReportsScreen() {
</Text> </Text>
{MOCK_REPORTS.map((r) => ( {MOCK_REPORTS.map((r) => (
<Card <Card key={r.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white">
key={r.id}
className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"
>
<Pressable> <Pressable>
<CardContent className="flex-row items-center py-4 pl-4 pr-3"> <CardContent className="flex-row items-center py-4 pl-4 pr-3">
<View className="mr-3 rounded-xl bg-primary/10 p-2"> <View className="mr-3 rounded-xl bg-primary/10 p-2">
@ -37,12 +32,8 @@ export default function ReportsScreen() {
</View> </View>
<View className="flex-1"> <View className="flex-1">
<Text className="font-semibold text-gray-900">{r.title}</Text> <Text className="font-semibold text-gray-900">{r.title}</Text>
<Text className="text-muted-foreground mt-0.5 text-sm"> <Text className="text-muted-foreground mt-0.5 text-sm">{r.period}</Text>
{r.period} <Text className="text-muted-foreground mt-0.5 text-xs">Generated {r.generatedAt}</Text>
</Text>
<Text className="text-muted-foreground mt-0.5 text-xs">
Generated {r.generatedAt}
</Text>
</View> </View>
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
<Pressable className="rounded-lg bg-primary/10 p-2"> <Pressable className="rounded-lg bg-primary/10 p-2">
@ -55,11 +46,7 @@ export default function ReportsScreen() {
</Card> </Card>
))} ))}
<Button <Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}>
variant="outline"
className="mt-4 rounded-xl border-border"
onPress={() => nav.back()}
>
<Text className="font-medium">Back</Text> <Text className="font-medium">Back</Text>
</Button> </Button>
</ScrollView> </ScrollView>

View File

@ -1,15 +1,13 @@
import { View, ScrollView, Pressable } from "react-native"; import { View, ScrollView, Pressable } from 'react-native';
import { useSirouRouter } from "@sirou/react-native"; import { router } from 'expo-router';
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Settings, Bell, Globe, ChevronRight, Info } from '@/lib/icons';
import { Settings, Bell, Globe, ChevronRight, Info } from "@/lib/icons";
const PRIMARY = "#ea580c"; const PRIMARY = '#ea580c';
export default function SettingsScreen() { export default function SettingsScreen() {
const nav = useSirouRouter<AppRoutes>();
return ( return (
<ScrollView <ScrollView
className="flex-1 bg-[#f5f5f5]" className="flex-1 bg-[#f5f5f5]"
@ -28,7 +26,7 @@ export default function SettingsScreen() {
<CardContent className="gap-0"> <CardContent className="gap-0">
<Pressable <Pressable
className="flex-row items-center justify-between border-b border-border py-3" className="flex-row items-center justify-between border-b border-border py-3"
onPress={() => nav.go("notifications/settings")} onPress={() => router.push('/notifications/settings')}
> >
<View className="flex-row items-center gap-3"> <View className="flex-row items-center gap-3">
<Bell color="#71717a" size={20} strokeWidth={2} /> <Bell color="#71717a" size={20} strokeWidth={2} />
@ -62,16 +60,11 @@ export default function SettingsScreen() {
<View className="rounded-xl border border-border bg-white p-4"> <View className="rounded-xl border border-border bg-white p-4">
<Text className="text-muted-foreground text-xs"> <Text className="text-muted-foreground text-xs">
API: Invoices, Proforma, Payments, Reports, Documents, Notifications API: Invoices, Proforma, Payments, Reports, Documents, Notifications see swagger.json and README for integration.
see swagger.json and README for integration.
</Text> </Text>
</View> </View>
<Button <Button variant="outline" className="mt-6 rounded-xl border-border" onPress={() => router.back()}>
variant="outline"
className="mt-6 rounded-xl border-border"
onPress={() => nav.back()}
>
<Text className="font-medium">Back</Text> <Text className="font-medium">Back</Text>
</Button> </Button>
</ScrollView> </ScrollView>

View File

@ -1,366 +0,0 @@
import React, { useState } from "react";
import {
View,
ScrollView,
Pressable,
PermissionsAndroid,
Platform,
ActivityIndicator,
} from "react-native";
import { Text } from "@/components/ui/text";
import { Card } from "@/components/ui/card";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { toast } from "@/lib/toast-store";
import { ArrowLeft, MessageSquare, RefreshCw } from "@/lib/icons";
import { useColorScheme } from "nativewind";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
// Installed via: npm install react-native-get-sms-android --legacy-peer-deps
// Android only — iOS does not permit reading SMS
let SmsAndroid: any = null;
try {
const smsModule = require("react-native-get-sms-android");
SmsAndroid = smsModule.default || smsModule;
} catch (e) {
console.log("[SMS] Module require failed:", e);
}
// Keywords to match Ethiopian banking SMS messages
const BANK_KEYWORDS = ["CBE", "DashenBank", "Dashen", "127", "telebirr"];
interface SmsMessage {
_id: string;
address: string;
body: string;
date: number;
date_sent: number;
}
interface ParsedPayment {
smsId: string;
bank: string;
amount: string;
ref: string;
date: number;
body: string;
sender: string;
}
export default function SmsScanScreen() {
const nav = useSirouRouter<AppRoutes>();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [messages, setMessages] = useState<SmsMessage[]>([]);
const [loading, setLoading] = useState(false);
const [scanned, setScanned] = useState(false);
const scanSms = async () => {
if (Platform.OS !== "android") {
toast.error("Android Only", "SMS reading is only supported on Android.");
return;
}
if (!SmsAndroid) {
toast.error(
"Native Module Error",
"SMS scanning requires a Development Build. Expo Go does not support this package.",
);
return;
}
setLoading(true);
try {
// Request SMS permission
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_SMS,
{
title: "SMS Access Required",
message:
"Yaltopia needs access to read your banking SMS messages to match payments.",
buttonPositive: "Allow",
buttonNegative: "Deny",
},
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
toast.error("Permission Denied", "SMS access was not granted.");
return;
}
// Only look at messages from the past 20 minutes
const fiveMinutesAgo = Date.now() - 20 * 60 * 1000;
const filter = {
box: "inbox",
minDate: fiveMinutesAgo,
maxCount: 50,
};
SmsAndroid.list(
JSON.stringify(filter),
(fail: string) => {
console.error("[SMS] Failed to read:", fail);
toast.error("Read Failed", "Could not read SMS messages.");
setLoading(false);
},
(count: number, smsList: string) => {
const allMessages: SmsMessage[] = JSON.parse(smsList);
// Filter for banking messages only
const bankMessages = allMessages.filter((sms) => {
const body = sms.body?.toUpperCase() || "";
const address = sms.address?.toUpperCase() || "";
return BANK_KEYWORDS.some(
(kw) =>
body.includes(kw.toUpperCase()) ||
address.includes(kw.toUpperCase()),
);
});
setMessages(bankMessages);
setScanned(true);
setLoading(false);
if (bankMessages.length === 0) {
toast.info(
"No Matches",
"No banking SMS found in the last 5 minutes.",
);
} else {
toast.success(
"Found!",
`${bankMessages.length} banking message(s) detected.`,
);
}
},
);
} catch (err: any) {
console.error("[SMS] Error:", err);
toast.error("Error", err.message);
setLoading(false);
}
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const parseMessage = (sms: SmsMessage): ParsedPayment | null => {
const body = sms.body;
const addr = sms.address.toUpperCase();
const text = (body + addr).toUpperCase();
let bank = "Unknown";
let amount = "";
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 (
<ScreenWrapper className="bg-background">
{/* Header */}
<View className="px-6 pt-4 flex-row justify-between items-center">
<Pressable
onPress={() => nav.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<ArrowLeft color={isDark ? "#fff" : "#0f172a"} size={20} />
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
Scan SMS
</Text>
<View className="w-10" /> {/* Spacer */}
</View>
<View className="px-5 pt-6 pb-4">
<Text variant="h3" className="text-foreground font-bold">
Scan SMS
</Text>
<Text variant="muted" className="mt-1">
Finds banking messages from the last 5 minutes
</Text>
</View>
{/* Scan Button */}
<View className="px-5 mb-4">
<Pressable
onPress={scanSms}
disabled={loading}
className="h-12 rounded-[10px] bg-primary items-center justify-center flex-row gap-2"
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<>
<RefreshCw color="white" size={16} />
<Text className="text-white font-bold uppercase tracking-widest text-xs">
{scanned ? "Scan Again" : "Scan Now"}
</Text>
</>
)}
</Pressable>
</View>
<ScrollView
className="flex-1 px-5"
contentContainerStyle={{ paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
>
{!scanned && !loading && (
<View className="flex-1 items-center justify-center py-20 gap-4">
<View className="bg-primary/10 p-6 rounded-[24px]">
<MessageSquare
size={40}
className="text-primary"
color="#ea580c"
strokeWidth={1.5}
/>
</View>
<Text variant="muted" className="text-center px-10">
Tap "Scan Now" to search for CBE, Dashen Bank, and Telebirr
messages from the last 5 minutes.
</Text>
</View>
)}
{scanned && messages.length === 0 && (
<View className="flex-1 items-center justify-center py-20 gap-4">
<Text variant="muted" className="text-center">
No banking messages found in the last 5 minutes.
</Text>
</View>
)}
<View className="gap-4">
{messages.map((sms) => {
const parsed = parseMessage(sms);
if (!parsed) return null;
const bankColor = getBankColor(parsed.bank);
return (
<Card
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
className="px-3 py-1 rounded-full"
style={{ backgroundColor: bankColor + "15" }}
>
<Text
className="text-xs font-bold uppercase tracking-wider"
style={{ color: bankColor }}
>
{parsed.bank}
</Text>
</View>
<Text variant="muted" className="text-xs">
{formatTime(sms.date)}
</Text>
</View>
{/* 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 className="text-foreground font-bold text-sm">
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>
</Card>
);
})}
</View>
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -1,86 +0,0 @@
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

@ -1,258 +0,0 @@
import React, { useState } from "react";
import {
View,
ScrollView,
TextInput,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
useColorScheme,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import {
Mail,
Lock,
User,
Phone,
ArrowRight,
ShieldCheck,
ChevronDown,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { api } from "@/lib/api";
import { toast } from "@/lib/toast-store";
import { PickerModal, SelectOption } from "@/components/PickerModal";
import { getPlaceholderColor } from "@/lib/colors";
const ROLES = ["VIEWER", "EMPLOYEE", "ACCOUNTANT", "CUSTOMER_SERVICE"];
export default function CreateUserScreen() {
const nav = useSirouRouter<AppRoutes>();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [form, setForm] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
password: "",
role: "VIEWER",
});
const [showRolePicker, setShowRolePicker] = useState(false);
const [loading, setLoading] = useState(false);
const handleCreate = async () => {
const { firstName, lastName, email, phone, password, role } = form;
if (!firstName || !lastName || !email || !phone || !password) {
toast.error("Required Fields", "Please fill in all fields");
return;
}
setLoading(true);
try {
// Prepend +251 if not present
const formattedPhone = phone.startsWith("+") ? phone : `+251${phone}`;
await api.users.create({
body: {
...form,
phone: formattedPhone,
},
});
toast.success(
"User Created!",
`${firstName} has been added to the system.`,
);
nav.back();
} catch (err: any) {
toast.error(
"Creation Failed",
err.message || "Failed to create user account",
);
} finally {
setLoading(false);
}
};
const updateForm = (key: keyof typeof form, val: string) =>
setForm((prev) => ({ ...prev, [key]: val }));
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Add New User" showBack />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1"
>
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 24, paddingBottom: 60 }}
keyboardShouldPersistTaps="handled"
>
<View className="mb-8">
<Text variant="h3" className="font-bold text-foreground">
User Details
</Text>
<Text variant="muted" className="mt-1">
Configure credentials and system access
</Text>
</View>
<View className="gap-5">
{/* Identity Group */}
<View className="flex-row gap-4">
<View className="flex-1">
<Text variant="small" className="font-semibold mb-2 ml-1">
First Name
</Text>
<View className="rounded-xl px-4 border border-border h-12 justify-center">
<TextInput
className="text-foreground"
placeholder="First Name"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.firstName}
onChangeText={(v) => updateForm("firstName", v)}
/>
</View>
</View>
<View className="flex-1">
<Text variant="small" className="font-semibold mb-2 ml-1">
Last Name
</Text>
<View className="rounded-xl px-4 border border-border h-12 justify-center">
<TextInput
className="text-foreground"
placeholder="Last Name"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.lastName}
onChangeText={(v) => updateForm("lastName", v)}
/>
</View>
</View>
</View>
{/* Email */}
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Email Address
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<Mail size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="email@company.com"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.email}
onChangeText={(v) => updateForm("email", v)}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
</View>
{/* Phone */}
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Phone Number
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<Phone size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="911 234 567"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.phone}
onChangeText={(v) => updateForm("phone", v)}
keyboardType="phone-pad"
/>
</View>
</View>
{/* Role - Dropdown */}
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
System Role
</Text>
<Pressable
onPress={() => setShowRolePicker(true)}
className="flex-row items-center rounded-xl px-4 border border-border h-12"
>
<ShieldCheck size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<Text className="flex-1 ml-3 text-foreground text-sm font-medium">
{form.role}
</Text>
<ChevronDown size={18} color={isDark ? "#94a3b8" : "#64748b"} />
</Pressable>
</View>
{/* Password */}
<View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Initial Password
</Text>
<View className="flex-row items-center rounded-xl px-4 border border-border h-12">
<Lock size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<TextInput
className="flex-1 ml-3 text-foreground"
placeholder="••••••••"
placeholderTextColor={getPlaceholderColor(isDark)}
value={form.password}
onChangeText={(v) => updateForm("password", v)}
secureTextEntry
/>
</View>
</View>
<Button
className="h-14 bg-primary rounded-[10px] shadow-lg shadow-primary/30 mt-6"
onPress={handleCreate}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<>
<Text className="text-white font-bold text-base mr-2">
Create User
</Text>
<ArrowRight color="white" size={18} strokeWidth={2.5} />
</>
)}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
<PickerModal
visible={showRolePicker}
onClose={() => setShowRolePicker(false)}
title="Select System Role"
>
{ROLES.map((role) => (
<SelectOption
key={role}
label={role}
value={role}
selected={form.role === role}
onSelect={(v) => {
updateForm("role", v);
setShowRolePicker(false);
}}
/>
))}
</PickerModal>
</ScreenWrapper>
);
}

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.

Before

Width:  |  Height:  |  Size: 395 KiB

View File

@ -2,9 +2,8 @@ module.exports = function (api) {
api.cache(true); api.cache(true);
return { return {
presets: [ presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }], ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
"nativewind/babel", 'nativewind/babel',
], ],
plugins: ["react-native-reanimated/plugin"],
}; };
}; };

View File

@ -1,160 +0,0 @@
import React, { useState } from "react";
import { View, Pressable, StyleSheet } from "react-native";
import { Text } from "@/components/ui/text";
import { ArrowLeft, ArrowRight, ChevronDown } from "@/lib/icons";
import { useColorScheme } from "nativewind";
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
interface CalendarGridProps {
onSelect: (v: string) => void;
selectedDate: string;
}
export function CalendarGrid({ onSelect, selectedDate }: CalendarGridProps) {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const initialDate = selectedDate ? new Date(selectedDate) : new Date();
const [viewDate, setViewDate] = useState(
new Date(initialDate.getFullYear(), initialDate.getMonth(), 1),
);
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
// Days in current month
const daysInMonth = new Date(year, month + 1, 0).getDate();
// Starting day of the week (0-6), where 0 is Sunday
let firstDayOfMonth = new Date(year, month, 1).getDay();
// Adjust for Monday start: Mon=0 ... Sun=6
firstDayOfMonth = firstDayOfMonth === 0 ? 6 : firstDayOfMonth - 1;
// Days in previous month to fill the start
const prevMonthLastDay = new Date(year, month, 0).getDate();
const changeMonth = (delta: number) => {
setViewDate(new Date(year, month + delta, 1));
};
const days = [];
// Fill previous month days (muted)
for (let i = firstDayOfMonth - 1; i >= 0; i--) {
days.push({
date: new Date(year, month - 1, prevMonthLastDay - i),
currentMonth: false,
});
}
// Fill current month days
for (let i = 1; i <= daysInMonth; i++) {
days.push({
date: new Date(year, month, i),
currentMonth: true,
});
}
// Fill next month days (muted) to complete the grid (usually 42 cells for 6 weeks)
const remaining = 42 - days.length;
for (let i = 1; i <= remaining; i++) {
days.push({
date: new Date(year, month + 1, i),
currentMonth: false,
});
}
return (
<View className="bg-card px-2 pb-6">
<View className="flex-row justify-between items-center mb-10 mt-2">
<Pressable
onPress={() => changeMonth(-1)}
className="h-12 w-12 bg-white rounded-[12px] items-center justify-center border border-border"
style={isDark ? { backgroundColor: "#1e1e1e" } : undefined}
>
<ArrowLeft size={18} color="#64748b" strokeWidth={2} />
</Pressable>
<View className="flex-row items-center gap-2">
<Text className="text-foreground text-base font-medium tracking-tight">
{MONTHS[month]} {year}
</Text>
</View>
<Pressable
onPress={() => changeMonth(1)}
className="h-12 w-12 bg-white rounded-[12px] items-center justify-center border border-border"
style={isDark ? { backgroundColor: "#1e1e1e" } : undefined}
>
<ArrowRight size={18} color="#64748b" strokeWidth={2} />
</Pressable>
</View>
{/* WeekDays Header: Mo Tu We Th Fr Sa Su */}
<View className="flex-row mb-6">
{WEEKDAYS.map((day, idx) => (
<View key={idx} className="w-[14.28%] items-center">
<Text className="text-[13px] font-semibold text-slate-400 opacity-80">
{day}
</Text>
</View>
))}
</View>
{/* Grid */}
<View className="flex-row flex-wrap">
{days.map((item, i) => {
const d = item.date;
const iso = d.toISOString().split("T")[0];
const isSelected = iso === selectedDate && item.currentMonth;
const isToday = iso === new Date().toISOString().split("T")[0];
return (
<View
key={i}
className="w-[14.28%] aspect-square items-center justify-center mb-1"
>
<Pressable
onPress={() => onSelect(iso)}
className={`w-11 h-11 items-center justify-center rounded-full ${
isSelected ? "bg-primary" : ""
}`}
>
<Text
className={`text-[15px] ${
isSelected
? "text-white font-bold"
: item.currentMonth
? "text-foreground font-medium"
: "text-slate-300 font-medium"
} ${isToday && !isSelected ? "text-primary" : ""}`}
>
{d.getDate()}
</Text>
{isToday && !isSelected && (
<View className="absolute bottom-1 w-2 h-2 bg-primary rounded-full" />
)}
</Pressable>
</View>
);
})}
</View>
</View>
);
}

View File

@ -1,78 +0,0 @@
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

@ -1,39 +0,0 @@
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

@ -1,123 +0,0 @@
import React from "react";
import {
View,
Modal,
Pressable,
ScrollView,
Dimensions,
StyleSheet,
} from "react-native";
import { Text } from "@/components/ui/text";
import { X, Check } from "@/lib/icons";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { useColorScheme } from "nativewind";
const { height: SCREEN_HEIGHT } = Dimensions.get("window");
interface PickerModalProps {
visible: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function PickerModal({
visible,
onClose,
title,
children,
}: PickerModalProps) {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 justify-end">
<Pressable
className="bg-card rounded-t-[36px] overflow-hidden border-t border-border/5"
style={{
maxHeight: SCREEN_HEIGHT * 0.8,
shadowColor: "#000",
shadowOffset: { width: 0, height: -10 },
shadowOpacity: 0.1,
shadowRadius: 20,
elevation: 20,
}}
onPress={(e) => e.stopPropagation()}
>
{/* Drag Handle */}
<View className="items-center pt-3 pb-1">
<View className="w-10 h-1 bg-border/20 rounded-full" />
</View>
{/* Header */}
<View className="px-6 pb-4 pt-2 flex-row justify-between items-center">
<View className="w-10" />
<Text variant="h4" className="text-foreground tracking-tight">
{title}
</Text>
<Pressable
onPress={onClose}
className="h-10 w-10 bg-secondary/80 rounded-full items-center justify-center border border-border/10"
>
<X
size={16}
color={isDark ? "#f1f5f9" : "#0f172a"}
strokeWidth={2.5}
/>
</Pressable>
</View>
<ScrollView
className="p-5 pt-0"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 40 }}
>
{children}
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
);
}
export function SelectOption({
label,
value,
selected,
onSelect,
}: {
label: string;
value: string;
selected: boolean;
onSelect: (v: string) => void;
}) {
return (
<Pressable
onPress={() => onSelect(value)}
className={`flex-row items-center justify-between p-4 mb-3 rounded-[6px] border ${
selected
? "bg-primary/5 border-primary/20"
: "bg-secondary/20 border-border/5"
}`}
>
<Text
className={`font-bold text-[15px] ${selected ? "text-primary" : "text-foreground"}`}
>
{label}
</Text>
<View
className={`h-5 w-5 rounded-full items-center justify-center ${selected ? "bg-primary" : "border border-border/20"}`}
>
{selected && <Check size={12} color="white" strokeWidth={4} />}
</View>
</Pressable>
);
}

View File

@ -1,40 +0,0 @@
import React from "react";
import {
View,
ViewProps,
SafeAreaView,
Platform,
StatusBar,
useColorScheme,
} from "react-native";
import { cn } from "@/lib/utils";
interface ScreenWrapperProps extends ViewProps {
children: React.ReactNode;
withSafeArea?: boolean;
fixedHeader?: boolean;
}
export function ScreenWrapper({
children,
className,
containerClassName,
withSafeArea = true,
fixedHeader = false,
...props
}: ScreenWrapperProps & { containerClassName?: string }) {
const Container = withSafeArea ? SafeAreaView : View;
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
return (
<View
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>
</View>
);
}

View File

@ -1,59 +0,0 @@
import React from "react";
import { View, ViewProps, Platform } from "react-native";
import { cn } from "@/lib/utils";
import { useColorScheme } from "nativewind";
interface ShadowWrapperProps extends ViewProps {
level?: "none" | "xs" | "sm" | "md" | "lg" | "xl";
children: React.ReactNode;
className?: string;
}
export function ShadowWrapper({
level = "md",
className,
children,
...props
}: ShadowWrapperProps) {
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const shadowClasses = {
none: "",
xs: isDark ? "" : "shadow-sm shadow-slate-200/30",
sm: isDark ? "" : "shadow-sm shadow-slate-200/50",
md: isDark ? "" : "shadow-md shadow-slate-200/60",
lg: isDark ? "" : "shadow-xl shadow-slate-200/70",
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 (
<View
className={cn(shadowClasses[level], className)}
style={[androidBaseStyle, props.style as any]}
{...props}
>
{children}
</View>
);
}

View File

@ -1,111 +0,0 @@
import { View, Image, Pressable, useColorScheme } from "react-native";
import { Text } from "@/components/ui/text";
import { ArrowLeft, Bell, Settings, Info } from "@/lib/icons";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { useAuthStore } from "@/lib/auth-store";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
interface StandardHeaderProps {
title?: string;
showBack?: boolean;
rightAction?: "notificationsSettings" | "companyInfo";
}
export function StandardHeader({
title,
showBack,
rightAction,
}: StandardHeaderProps) {
const user = useAuthStore((state) => state.user);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const nav = useSirouRouter<AppRoutes>();
// Fallback avatar if user has no profile picture
const avatarUri =
user?.avatar ||
"https://ui-avatars.com/api/?name=" +
encodeURIComponent(`${user?.firstName} ${user?.lastName}`) +
"&background=ea580c&color=fff";
return (
<View className="px-5 pt-4 pb-2 flex-row justify-between items-center bg-background">
<View className="flex-1 flex-row items-center gap-3">
{showBack && (
<Pressable
onPress={() => nav.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<ArrowLeft color={isDark ? "#f1f5f9" : "#0f172a"} size={20} />
</Pressable>
)}
{!title ? (
<View className="flex-row items-center gap-3 ml-1">
<View>
<Pressable
onPress={() => nav.go("profile")}
className="h-[40px] w-[40px] rounded-full overflow-hidden"
>
<Image source={{ uri: avatarUri }} className="h-full w-full" />
</Pressable>
</View>
<View>
<Text
variant="muted"
className="text-[10px] uppercase tracking-widest font-bold"
>
Welcome back,
</Text>
<Text variant="h4" className="text-foreground leading-tight">
{user?.firstName + " " + user?.lastName || "User"}
</Text>
</View>
</View>
) : (
<View className="flex-1 items-center ">
<Text variant="h4" className="text-foreground font-semibold">
{title}
</Text>
</View>
)}
</View>
{!title && (
<Pressable
className="rounded-full p-2.5 border border-border"
onPress={() => nav.go("notifications/index")}
>
<Bell
color={isDark ? "#f1f5f9" : "#0f172a"}
size={20}
strokeWidth={2}
/>
</Pressable>
)}
{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 File

@ -1,41 +0,0 @@
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

@ -1,146 +0,0 @@
import React, { useEffect } from "react";
import { View, StyleSheet, Dimensions } from "react-native";
import { Text } from "@/components/ui/text";
import { useToast, ToastType } from "@/lib/toast-store";
import {
CheckCircle2,
AlertCircle,
AlertTriangle,
Lightbulb,
} from "@/lib/icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.4;
const TOAST_VARIANTS: Record<
ToastType,
{
bg: string;
border: string;
icon: React.ReactNode;
}
> = {
success: {
bg: "rgba(34, 197, 94, 0.05)",
border: "#22c55e",
icon: <CheckCircle2 size={24} color="#22c55e" />,
},
info: {
bg: "rgba(14, 165, 233, 0.05)",
border: "#0ea5e9",
icon: <Lightbulb size={24} color="#0ea5e9" />,
},
warning: {
bg: "rgba(245, 158, 11, 0.05)",
border: "#f59e0b",
icon: <AlertTriangle size={24} color="#f59e0b" />,
},
error: {
bg: "rgba(239, 68, 68, 0.05)",
border: "#ef4444",
icon: <AlertCircle size={24} color="#ef4444" />,
},
};
export function Toast() {
const { visible, type, title, message, hide, duration } = useToast();
const insets = useSafeAreaInsets();
const opacity = useSharedValue(0);
const translateY = useSharedValue(-100);
const translateX = useSharedValue(0);
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 300 });
translateY.value = withSpring(0, { damping: 15, stiffness: 100 });
translateX.value = 0;
const timer = setTimeout(() => {
handleHide();
}, duration);
return () => clearTimeout(timer);
}
}, [visible]);
const handleHide = () => {
opacity.value = withTiming(0, { duration: 300 });
translateY.value = withTiming(-100, { duration: 300 }, () => {
runOnJS(hide)();
});
};
const swipeGesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
})
.onEnd((event) => {
if (Math.abs(event.translationX) > SWIPE_THRESHOLD) {
translateX.value = withTiming(
event.translationX > 0 ? SCREEN_WIDTH : -SCREEN_WIDTH,
{ duration: 200 },
() => runOnJS(handleHide)(),
);
} else {
translateX.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [
{ translateY: translateY.value },
{ translateX: translateX.value },
],
}));
if (!visible) return null;
const variant = TOAST_VARIANTS[type];
return (
<GestureDetector gesture={swipeGesture}>
<Animated.View
style={[
styles.container,
{
top: insets.top + 10,
borderColor: variant.border,
},
animatedStyle,
]}
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="flex-1">
<Text className="font-bold text-[14px] text-foreground mb-1 leading-tight">
{title}
</Text>
<Text className="text-muted-foreground text-xs font-medium leading-normal">
{message}
</Text>
</View>
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
container: {
position: "absolute",
left: 16,
right: 16,
zIndex: 9999,
},
});

View File

@ -1,102 +1,91 @@
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 dark:shadow-none shadow-black/5", 'bg-primary active:bg-primary/90 shadow-sm 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 dark:shadow-none shadow-black/5", 'bg-destructive active:bg-destructive/90 dark:bg-destructive/60 shadow-sm 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 dark:shadow-none 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 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 dark:shadow-none shadow-black/5", 'bg-secondary active:bg-secondary/80 shadow-sm 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( default: cn('h-10 px-4 py-2 sm:h-9', Platform.select({ web: 'has-[>svg]:px-3' })),
"h-10 px-4 py-2 sm:h-9", sm: cn('h-9 gap-1.5 rounded-md px-3 sm:h-8', Platform.select({ web: 'has-[>svg]:px-2.5' })),
Platform.select({ web: "has-[>svg]:px-3" }), 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',
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({ Platform.select({ web: 'underline-offset-4 hover:underline group-hover:underline' })
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> &
@ -107,11 +96,7 @@ 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( className={cn(props.disabled && 'opacity-50', buttonVariants({ variant, size }), className)}
props.disabled && "opacity-50",
buttonVariants({ variant, size }),
className,
)}
role="button" role="button"
{...props} {...props}
/> />

View File

@ -1,26 +1,23 @@
import { Text, TextClassContext } from "@/components/ui/text"; import { Text, TextClassContext } from '@/components/ui/text';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from 'react-native';
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">
<View <View
className={cn("bg-card flex flex-col border border-border gap-4 rounded-xl ", className)} className={cn(
'bg-card border-border flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-black/5',
className
)}
{...props} {...props}
/> />
</TextClassContext.Provider> </TextClassContext.Provider>
); );
} }
function CardHeader({ function CardHeader({ className, ...props }: ViewProps & React.RefAttributes<View>) {
className, return <View className={cn('flex flex-col gap-1.5 px-6', className)} {...props} />;
...props
}: ViewProps & React.RefAttributes<View>) {
return (
<View className={cn("flex flex-col gap-1.5 px-6", className)} {...props} />
);
} }
function CardTitle({ function CardTitle({
@ -31,7 +28,7 @@ function CardTitle({
<Text <Text
role="heading" role="heading"
aria-level={3} aria-level={3}
className={cn("font-semibold leading-none", className)} className={cn('font-semibold leading-none', className)}
{...props} {...props}
/> />
); );
@ -41,38 +38,15 @@ function CardDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) { }: React.ComponentProps<typeof Text> & React.RefAttributes<Text>) {
return ( return <Text className={cn('text-muted-foreground text-sm', className)} {...props} />;
<Text
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
} }
function CardContent({ function CardContent({ className, ...props }: ViewProps & React.RefAttributes<View>) {
className, return <View className={cn('px-6', className)} {...props} />;
...props
}: ViewProps & React.RefAttributes<View>) {
return <View className={cn("px-4", className)} {...props} />;
} }
function CardFooter({ function CardFooter({ className, ...props }: ViewProps & React.RefAttributes<View>) {
className, return <View className={cn('flex flex-row items-center px-6', className)} {...props} />;
...props
}: ViewProps & React.RefAttributes<View>) {
return (
<View
className={cn("flex flex-row items-center px-6", className)}
{...props}
/>
);
} }
export { export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@ -1,73 +1,65 @@
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import * as Slot from "@rn-primitives/slot"; 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 font-sans", 'text-foreground text-base',
Platform.select({ Platform.select({
web: "select-text", web: 'select-text',
}), })
), ),
{ {
variants: { variants: {
variant: { variant: {
default: "", default: '',
h1: cn( h1: cn(
"text-center text-4xl font-extrabold tracking-tight", 'text-center text-4xl font-extrabold tracking-tight',
Platform.select({ web: "scroll-m-20 text-balance" }), Platform.select({ web: 'scroll-m-20 text-balance' })
), ),
h2: cn( h2: cn(
"border-border border-b pb-2 text-3xl font-semibold tracking-tight", 'border-border border-b pb-2 text-3xl font-semibold tracking-tight',
Platform.select({ web: "scroll-m-20 first:mt-0" }), Platform.select({ web: 'scroll-m-20 first:mt-0' })
), ),
h3: cn( h3: cn('text-2xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
"text-2xl font-semibold tracking-tight", h4: cn('text-xl font-semibold tracking-tight', Platform.select({ web: 'scroll-m-20' })),
Platform.select({ web: "scroll-m-20" }), p: 'mt-3 leading-7 sm:mt-6',
), blockquote: 'mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6',
h4: cn(
"text-xl font-semibold tracking-tight",
Platform.select({ web: "scroll-m-20" }),
),
p: "mt-3 leading-7 sm:mt-6",
blockquote: "mt-4 border-l-2 pl-3 italic sm:mt-6 sm:pl-6",
code: cn( code: cn(
"bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold", 'bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold'
), ),
lead: "text-muted-foreground text-xl", lead: 'text-muted-foreground text-xl',
large: "text-lg font-semibold", large: 'text-lg font-semibold',
small: "text-sm font-medium leading-none", small: 'text-sm font-medium leading-none',
muted: "text-muted-foreground text-sm", muted: 'text-muted-foreground text-sm',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
},
}, },
}
); );
type TextVariantProps = VariantProps<typeof textVariants>; type TextVariantProps = VariantProps<typeof textVariants>;
type TextVariant = NonNullable<TextVariantProps["variant"]>; type TextVariant = NonNullable<TextVariantProps['variant']>;
const ROLE: Partial<Record<TextVariant, Role>> = { const ROLE: Partial<Record<TextVariant, Role>> = {
h1: "heading", h1: 'heading',
h2: "heading", h2: 'heading',
h3: "heading", h3: 'heading',
h4: "heading", h4: 'heading',
blockquote: Platform.select({ web: "blockquote" as Role }), blockquote: Platform.select({ web: 'blockquote' as Role }),
code: Platform.select({ web: "code" as Role }), code: Platform.select({ web: 'code' as Role }),
}; };
const ARIA_LEVEL: Partial<Record<TextVariant, string>> = { const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
h1: "1", h1: '1',
h2: "2", h2: '2',
h3: "3", h3: '3',
h4: "4", h4: '4',
}; };
const TextClassContext = React.createContext<string | undefined>(undefined); const TextClassContext = React.createContext<string | undefined>(undefined);
@ -75,7 +67,7 @@ const TextClassContext = React.createContext<string | undefined>(undefined);
function Text({ function Text({
className, className,
asChild = false, asChild = false,
variant = "default", variant = 'default',
...props ...props
}: React.ComponentProps<typeof RNText> & }: React.ComponentProps<typeof RNText> &
TextVariantProps & TextVariantProps &
@ -84,14 +76,11 @@ 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

@ -1,24 +0,0 @@
{
"cli": {
"version": ">= 18.0.5",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View File

@ -4,47 +4,57 @@
@layer base { @layer base {
:root { :root {
--background: 255,255,255; --background: 0 0% 100%;
--foreground: 37,22,21; --foreground: 0 0% 3.9%;
--card: 255,255,255; --card: 0 0% 100%;
--card-foreground: 37,22,21; --card-foreground: 0 0% 3.9%;
--popover: 255,249,244; --popover: 0 0% 100%;
--popover-foreground: 37,22,21; --popover-foreground: 0 0% 3.9%;
--primary: 228, 98, 18; --primary: 24 90% 48%;
--primary-foreground: 255,249,244; --primary-foreground: 0 0% 100%;
--secondary: 255,226,216; --secondary: 0 0% 96.1%;
--secondary-foreground: 66,37,32; --secondary-foreground: 0 0% 9%;
--muted: 255,234,227; --muted: 0 0% 96.1%;
--muted-foreground: 118,93,88; --muted-foreground: 0 0% 45.1%;
--accent: 255,222,207; --accent: 0 0% 96.1%;
--accent-foreground: 66,37,32; --accent-foreground: 0 0% 9%;
--destructive: 239,67,94; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 255,249,244; --destructive-foreground: 0 0% 98%;
--border: 237,213,209; --border: 0 0% 89.8%;
--input: 244,206,198; --input: 0 0% 89.8%;
--ring: 233,87,82; --ring: 0 0% 63%;
--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: 22,22,22; --background: 0 0% 3.9%;
--foreground: 255,241,238; --foreground: 0 0% 98%;
--card: 31,31,31; --card: 0 0% 3.9%;
--card-foreground: 255,241,238; --card-foreground: 0 0% 98%;
--popover: 31, 31, 31; --popover: 0 0% 3.9%;
--popover-foreground: 255,241,238; --popover-foreground: 0 0% 98%;
--primary: 228, 98, 18; --primary: 0 0% 98%;
--primary-foreground: 0,0,0; --primary-foreground: 0 0% 9%;
--secondary: 15, 9, 11; --secondary: 0 0% 14.9%;
--secondary-foreground: 255,241,238; --secondary-foreground: 0 0% 98%;
--muted: 9, 5, 6; --muted: 0 0% 14.9%;
--muted-foreground: 176,153,151; --muted-foreground: 0 0% 63.9%;
--accent: 228, 125, 251; --accent: 0 0% 14.9%;
--accent-foreground: 255,249,244; --accent-foreground: 0 0% 98%;
--destructive: 255,40,90; --destructive: 0 70.9% 59.4%;
--destructive-foreground: 255,249,244; --destructive-foreground: 0 0% 98%;
--border: 95, 95, 95; --border: 0 0% 14.9%;
--input: 16,9,10; --input: 0 0% 14.9%;
--ring: 151,170,81; --ring: 300 0% 45%;
--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

@ -1,73 +0,0 @@
# iOS Build Guide for Expo
This project uses the **Expo Managed Workflow**, which means the `ios` and `android` native directories are generated automatically via **Continuous Native Generation (CNG)**. You should not see or manually edit an `ios` folder in your project root.
---
## 1. Development (Expo Go)
The easiest way to build/run for iOS during development is using the **Expo Go** app on your iPhone.
1. Install **Expo Go** from the App Store.
2. Run the development server:
```bash
npx expo start
```
3. Scan the QR code with your Camera app to open the project in Expo Go.
---
## 2. Local Native Development (Prebuild)
If you need to test native modules, use a custom dev client, or specifically need the `ios` folder for debugging in Xcode:
1. Generate the native directories:
```bash
npx expo prebuild
```
_This will create the `ios` and `android` folders based on your `app.json` configuration._
2. Run on the iOS Simulator (requires macOS + Xcode):
```bash
npx expo run:ios
```
> [!WARNING]
> The `ios` folder is typically gitignored. In the managed workflow, any changes you make manually in the `ios` folder may be overwritten the next time you run `prebuild`. Always use `app.json` or config plugins for permanent configuration.
---
## 3. Production Builds (EAS Build)
To build a `.ipa` file for TestFlight or the App Store, the recommended way is using **Expo Application Services (EAS)**.
1. Install EAS CLI:
```bash
npm install -g eas-cli
```
2. Log in to your Expo account:
```bash
eas login
```
3. Configure the project (run once):
```bash
eas build:configure
```
4. Run a build for iOS:
```bash
eas build --platform ios
```
_EAS will handle certificates, provisioning profiles, and building on their servers (no macOS/Xcode required locally)._
---
## Summary of Commands
| Goal | Command |
| :----------------------- | :------------------------- |
| **Start Dev Server** | `npx expo start` |
| **Generate iOS Folder** | `npx expo prebuild` |
| **Run on iOS Simulator** | `npx expo run:ios` |
| **Build for Production** | `eas build --platform ios` |

View File

@ -1,119 +0,0 @@
import { Middleware } from "@simple-api/core";
import { useAuthStore } from "./auth-store";
/**
* Middleware to inject the authentication token into requests.
* Skips login, register, and refresh endpoints.
*/
export const authMiddleware: Middleware = async ({ config, options }, next) => {
const { token } = useAuthStore.getState();
// Don't send Authorization header for sensitive auth-related endpoints,
// EXCEPT for logout which needs to identify the session.
const isAuthPath =
config.path === "auth/login" ||
config.path === "auth/register" ||
config.path === "auth/refresh";
if (token && !isAuthPath) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token}`,
};
}
return await next(options);
};
/**
* Middleware to handle token refreshment on 401 Unauthorized errors.
*/
export const refreshMiddleware: Middleware = async (
{ config, options },
next,
) => {
try {
return await next(options);
} catch (error: any) {
const status = error.status || error.statusCode;
const { refreshToken, setAuth, logout } = useAuthStore.getState();
// Skip refresh logic for the login/refresh endpoints themselves
const isAuthPath =
config.path?.includes("auth/login") ||
config.path?.includes("auth/refresh");
if (status === 401 && refreshToken && !isAuthPath) {
console.log(
`[API Refresh] 401 detected for ${config.path}. Attempting refresh...`,
);
try {
// We call the refresh endpoint manually here to avoid circular dependencies with the 'api' object
const refreshUrl = `${config.baseUrl}auth/refresh`;
const response = await fetch(refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
refreshToken,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const refreshErr = new Error(
errorData.message ||
`Refresh failed with status ${response.status}`,
) as any;
refreshErr.status = response.status;
throw refreshErr;
}
const data = await response.json();
// Backend might return snake_case (access_token) or camelCase (accessToken)
// We handle both to be safe when using raw fetch
const accessToken = data.accessToken || data.access_token;
const newRefreshToken = data.refreshToken || data.refresh_token;
const user = data.user;
if (!accessToken) {
throw new Error("No access token returned from refresh");
}
setAuth(user, accessToken, newRefreshToken);
console.log("[API Refresh] Success. Retrying original request...");
// Update headers and retry
options.headers = {
...options.headers,
Authorization: `Bearer ${accessToken}`,
};
return await next(options);
} catch (refreshError: any) {
// Only logout if the refresh token itself is invalid (400, 401, 403)
// If it's a network error, we should NOT logout the user.
const refreshStatus = refreshError.status || refreshError.statusCode;
const isAuthError = refreshStatus === 401;
if (isAuthError) {
console.error("[API Refresh] Invalid refresh token. Logging out.");
logout();
} else {
console.error(
"[API Refresh] Network error or server issues during refresh. Staying logged in.",
);
}
throw refreshError;
}
}
throw error;
}
};

View File

@ -1,119 +0,0 @@
import {
createApi,
createLoggerMiddleware,
createTransformerMiddleware,
} from "@simple-api/core";
import { authMiddleware, refreshMiddleware } from "./api-middlewares";
// Trailing slash is essential for relative path resolution
export const BASE_URL = "https://api.yaltopiaticket.com/";
/**
* Central API client using simple-api
*/
export const api = createApi({
baseUrl: BASE_URL,
middleware: [
createLoggerMiddleware(),
createTransformerMiddleware(),
refreshMiddleware,
],
services: {
notifications: {
middleware: [authMiddleware],
endpoints: {
getAll: { method: "GET", path: "notifications" },
settings: { method: "GET", path: "notifications/settings" },
update: { method: "PUT", path: "notifications/settings" },
},
},
news: {
middleware: [authMiddleware],
endpoints: {
getAll: { method: "GET", path: "news" },
getLatest: { method: "GET", path: "news/latest" },
},
},
auth: {
middleware: [authMiddleware],
endpoints: {
login: { method: "POST", path: "auth/login" },
register: { method: "POST", path: "auth/login-or-register-owner" },
refresh: { method: "POST", path: "auth/refresh" },
logout: { method: "POST", path: "auth/logout" },
profile: { method: "GET", path: "auth/profile" },
googleMobile: { method: "POST", path: "auth/google/mobile" },
},
},
invoices: {
middleware: [authMiddleware],
endpoints: {
stats: { method: "GET", path: "invoices/stats" },
getAll: { method: "GET", path: "invoices" },
getById: { method: "GET", path: "invoices/:id" },
},
},
users: {
middleware: [authMiddleware],
endpoints: {
me: { method: "GET", path: "users/me" },
getAll: { method: "GET", path: "users" },
updateProfile: { method: "PUT", path: "users/me" },
create: { method: "POST", path: "auth/register" },
},
},
company: {
middleware: [authMiddleware],
endpoints: {
get: { method: "GET", path: "company" },
},
},
scan: {
middleware: [authMiddleware],
endpoints: {
invoice: { method: "POST", path: "scan/invoice" },
},
},
payments: {
middleware: [authMiddleware],
endpoints: {
getAll: { method: "GET", path: "payments" },
},
},
paymentRequests: {
middleware: [authMiddleware],
endpoints: {
create: { method: "POST", path: "payment-requests" },
},
},
proforma: {
middleware: [authMiddleware],
endpoints: {
getAll: { method: "GET", path: "proforma" },
getById: { method: "GET", path: "proforma/:id" },
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" },
},
},
},
});
export interface AuthResponse {
accessToken: string;
refreshToken: string;
user: any;
}
// Explicit exports for convenience and to avoid undefined access
export const authApi = api.auth;
export const newsApi = api.news;
export const invoicesApi = api.invoices;
export const proformaApi = api.proforma;
export const rbacApi = api.rbac;

View File

@ -1,46 +0,0 @@
import { RouteGuard, GuardResult } from "@sirou/core";
import { useAuthStore } from "./auth-store";
/**
* Authentication Guard
* Prevents unauthenticated users from accessing protected routes.
*/
export const authGuard: RouteGuard = {
name: "auth",
execute: async ({ route, meta }): Promise<GuardResult> => {
const { isAuthenticated } = useAuthStore.getState();
const requiresAuth = meta?.requiresAuth ?? false;
console.log(
`[AUTH_GUARD] checking: "${route}" (requiresAuth: ${requiresAuth}, auth: ${isAuthenticated})`,
);
if (requiresAuth && !isAuthenticated) {
console.log(`[AUTH_GUARD] DENIED -> redirect /login`);
return {
allowed: false,
redirect: "login", // Use name, not path
};
}
return { allowed: true };
},
};
export const guestGuard: RouteGuard = {
name: "guest",
execute: async ({ meta }): Promise<GuardResult> => {
const { isAuthenticated } = useAuthStore.getState();
const guestOnly = meta?.guestOnly ?? false;
if (guestOnly && isAuthenticated) {
console.log(`[GUEST_GUARD] Authenticated user blocked -> redirect /`);
return {
allowed: false,
redirect: "(tabs)", // Redirect to home if already logged in
};
}
return { allowed: true };
},
};

View File

@ -1,94 +0,0 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
export type UserRole =
| "ADMIN"
| "BUSINESS_OWNER"
| "EMPLOYEE"
| "ACCOUNTANT"
| "CUSTOMER_SERVICE"
| "AUDITOR"
| "VIEWER";
export interface User {
id: string;
email: string;
firstName: string;
lastName: string;
phone: string;
role: UserRole;
avatar?: string;
}
interface AuthState {
user: User | null;
token: string | null;
refreshToken: string | null;
permissions: string[];
isAuthenticated: boolean;
setAuth: (user: User, token: string, refreshToken?: string, permissions?: string[]) => void;
logout: () => Promise<void>;
updateUser: (user: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
refreshToken: null,
permissions: [],
isAuthenticated: false,
setAuth: (user, token, refreshToken = undefined, permissions = []) => {
console.log("[AuthStore] Setting auth state:", {
hasUser: !!user,
hasToken: !!token,
hasRefreshToken: !!refreshToken,
permissions,
});
set({
user,
token,
refreshToken: refreshToken ?? null,
permissions,
isAuthenticated: true,
});
},
logout: async () => {
console.log("[AuthStore] Logging out...");
const { isAuthenticated, token } = useAuthStore.getState();
if (isAuthenticated && token) {
try {
// Use require to avoid circularity and module flag errors
const { api } = require("./api");
await api.auth.logout();
console.log("[AuthStore] Server-side logout success.");
} catch (e: any) {
console.warn("[AuthStore] Server-side logout failed:", e.message);
}
}
set({
user: null,
token: null,
refreshToken: null,
permissions: [],
isAuthenticated: false,
});
},
updateUser: (updatedUser) =>
set((state) => {
console.log("[AuthStore] Updating user profile.");
return {
user: state.user ? { ...state.user, ...updatedUser } : null,
};
}),
}),
{
name: "yaltopia-auth-storage",
storage: createJSONStorage(() => AsyncStorage),
},
),
);

View File

@ -1,19 +0,0 @@
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),
};
};

View File

@ -34,42 +34,4 @@ export {
BarChart3, BarChart3,
Upload, Upload,
UserPlus, UserPlus,
Briefcase, } from 'lucide-react-native';
Layout,
Hash,
Star,
Trash2,
X,
History,
DraftingCompass,
Zap,
Tag,
CreditCard,
Building2,
ExternalLink,
Scan,
TrendingUp,
TrendingDown,
ShieldCheck,
HelpCircle,
ArrowUpRight,
Lock,
ArrowRight,
Eye,
EyeOff,
Github,
Phone,
Chrome,
Triangle,
Triangle as TrianglePlanets,
AlertTriangle,
Lightbulb,
Check,
MessageSquare,
RefreshCw,
Banknote,
Newspaper,
ChevronDown,
CalendarSearch,
Search,
} from "lucide-react-native";

View File

@ -1,23 +0,0 @@
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),
},
),
);

View File

@ -1,54 +0,0 @@
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

@ -1,163 +0,0 @@
import { defineRoutes } from "@sirou/core";
export const routes = defineRoutes({
// Root and Layouts
root: {
path: "/",
guards: ["auth"],
meta: { requiresAuth: true },
},
"(tabs)": {
path: "/(tabs)",
guards: ["auth"],
meta: { requiresAuth: true },
},
// Tabs
"(tabs)/index": {
path: "/(tabs)/index",
guards: ["auth"],
meta: { requiresAuth: true },
},
"(tabs)/payments": {
path: "/(tabs)/payments",
guards: ["auth"],
meta: { requiresAuth: true },
},
"(tabs)/scan": {
path: "/(tabs)/scan",
guards: ["auth"],
meta: { requiresAuth: true },
},
"(tabs)/proforma": {
path: "/(tabs)/proforma",
guards: ["auth"],
meta: { requiresAuth: true },
},
"(tabs)/news": {
path: "/(tabs)/news",
guards: ["auth"],
meta: { requiresAuth: true },
},
history: {
path: "/history",
guards: ["auth"],
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
"proforma/[id]": {
path: "/proforma/:id",
params: { id: "string" },
guards: ["auth"],
meta: { requiresAuth: true },
},
"proforma/create": {
path: "/proforma/create",
guards: ["auth"],
meta: { requiresAuth: true },
},
"proforma/edit": {
path: "/proforma/edit",
guards: ["auth"],
meta: { requiresAuth: true },
},
"payments/[id]": {
path: "/payments/:id",
params: { id: "string" },
guards: ["auth"],
meta: { requiresAuth: true },
},
"payment-requests/create": {
path: "/payment-requests/create",
guards: ["auth"],
meta: { requiresAuth: true },
},
"invoices/[id]": {
path: "/invoices/:id",
params: { id: "string" },
guards: ["auth"],
meta: { requiresAuth: true },
},
"notifications/index": {
path: "/notifications/index",
guards: ["auth"],
meta: { requiresAuth: true },
},
"notifications/settings": {
path: "/notifications/settings",
guards: ["auth"],
meta: { requiresAuth: true },
},
"reports/index": {
path: "/reports/index",
guards: ["auth"],
meta: { requiresAuth: true },
},
"documents/index": {
path: "/documents/index",
guards: ["auth"],
meta: { requiresAuth: true },
},
profile: {
path: "/profile",
guards: ["auth"],
meta: { requiresAuth: true },
},
"edit-profile": {
path: "/edit-profile",
guards: ["auth"],
meta: { requiresAuth: true, title: "Edit Profile" },
},
settings: {
path: "/settings",
guards: ["auth"],
meta: { requiresAuth: true },
},
"sms-scan": {
path: "/sms-scan",
guards: ["auth"],
meta: { requiresAuth: true },
},
company: {
path: "/company",
guards: ["auth"],
meta: { requiresAuth: true, title: "Company" },
},
"company-details": {
path: "/company/details",
guards: ["auth"],
meta: { requiresAuth: true, title: "Company details" },
},
"user/create": {
path: "/user/create",
guards: ["auth"],
meta: { requiresAuth: true, title: "Add User" },
},
// Public
login: {
path: "/login",
guards: ["guest"],
meta: { requiresAuth: false, guestOnly: true },
},
register: {
path: "/register",
guards: ["guest"],
meta: { requiresAuth: false, guestOnly: true },
},
});
export type AppRoutes = typeof routes;

View File

@ -1,53 +1,61 @@
import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native"; import { DarkTheme, DefaultTheme, type Theme } from '@react-navigation/native';
export const THEME = { export const THEME = {
light: { light: {
background: "rgba(255,243,238,1)", background: 'hsl(0 0% 100%)',
foreground: "rgba(37,22,21,1)", foreground: 'hsl(0 0% 3.9%)',
card: "rgba(255,249,244,1)", card: 'hsl(0 0% 100%)',
cardForeground: "rgba(37,22,21,1)", cardForeground: 'hsl(0 0% 3.9%)',
popover: "rgba(255,249,244,1)", popover: 'hsl(0 0% 100%)',
popoverForeground: "rgba(37,22,21,1)", popoverForeground: 'hsl(0 0% 3.9%)',
primary: "rgba(233,87,82,1)", primary: 'hsl(24 90% 48%)',
primaryForeground: "rgba(255,249,244,1)", primaryForeground: 'hsl(0 0% 100%)',
secondary: "rgba(255,226,216,1)", secondary: 'hsl(0 0% 96.1%)',
secondaryForeground: "rgba(66,37,32,1)", secondaryForeground: 'hsl(0 0% 9%)',
muted: "rgba(255,234,227,1)", muted: 'hsl(0 0% 96.1%)',
mutedForeground: "rgba(118,93,88,1)", mutedForeground: 'hsl(0 0% 45.1%)',
accent: "rgba(255,222,207,1)", accent: 'hsl(0 0% 96.1%)',
accentForeground: "rgba(66,37,32,1)", accentForeground: 'hsl(0 0% 9%)',
destructive: "rgba(239,67,94,1)", destructive: 'hsl(0 84.2% 60.2%)',
destructiveForeground: "rgba(255,249,244,1)", border: 'hsl(0 0% 89.8%)',
border: "rgba(237,213,209,1)", input: 'hsl(0 0% 89.8%)',
input: "rgba(244,206,198,1)", ring: 'hsl(0 0% 63%)',
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: "rgba(25,21,21,1)", background: 'hsl(0 0% 3.9%)',
foreground: "rgba(255,241,238,1)", foreground: 'hsl(0 0% 98%)',
card: "rgba(35,30,29,1)", card: 'hsl(0 0% 3.9%)',
cardForeground: "rgba(255,241,238,1)", cardForeground: 'hsl(0 0% 98%)',
popover: "rgba(35,30,29,1)", popover: 'hsl(0 0% 3.9%)',
popoverForeground: "rgba(255,241,238,1)", popoverForeground: 'hsl(0 0% 98%)',
primary: "rgba(233,87,82,1)", primary: 'hsl(0 0% 98%)',
primaryForeground: "rgba(0,0,0,1)", primaryForeground: 'hsl(0 0% 9%)',
secondary: "rgba(16,9,10,1)", secondary: 'hsl(0 0% 14.9%)',
secondaryForeground: "rgba(255,241,238,1)", secondaryForeground: 'hsl(0 0% 98%)',
muted: "rgba(9,5,5,1)", muted: 'hsl(0 0% 14.9%)',
mutedForeground: "rgba(176,153,151,1)", mutedForeground: 'hsl(0 0% 63.9%)',
accent: "rgba(197,156,221,1)", accent: 'hsl(0 0% 14.9%)',
accentForeground: "rgba(255,249,244,1)", accentForeground: 'hsl(0 0% 98%)',
destructive: "rgba(255,40,90,1)", destructive: 'hsl(0 70.9% 59.4%)',
destructiveForeground: "rgba(255,249,244,1)", border: 'hsl(0 0% 14.9%)',
border: "rgba(105,93,92,1)", input: 'hsl(0 0% 14.9%)',
input: "rgba(16,9,10,1)", ring: 'hsl(300 0% 45%)',
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;
export const NAV_THEME: Record<"light" | "dark", Theme> = { export const NAV_THEME: Record<'light' | 'dark', Theme> = {
light: { light: {
...DefaultTheme, ...DefaultTheme,
colors: { colors: {
@ -71,36 +79,3 @@ export const NAV_THEME: Record<"light" | "dark", Theme> = {
}, },
}, },
}; };
// ── Persistent theme helpers ──────────────────────────────────────
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect } from "react";
import { useColorScheme } from "nativewind";
export type AppTheme = "light" | "dark" | "system";
const THEME_KEY = "app_theme_preference";
export async function saveTheme(theme: AppTheme): Promise<void> {
try {
await AsyncStorage.setItem(THEME_KEY, theme);
} catch {}
}
export async function loadTheme(): Promise<AppTheme> {
try {
const v = await AsyncStorage.getItem(THEME_KEY);
if (v === "light" || v === "dark" || v === "system") return v;
} catch {}
return "system";
}
/** Drop this in the root _layout to restore the saved theme on every app launch. */
export function useRestoreTheme() {
const { setColorScheme } = useColorScheme();
useEffect(() => {
// Only set it once on load
loadTheme().then((t) => {
if (t) setColorScheme(t);
});
}, []);
}

View File

@ -1,46 +0,0 @@
import { create } from "zustand";
export type ToastType = "success" | "error" | "warning" | "info";
export interface ToastState {
visible: boolean;
type: ToastType;
title: string;
message: string;
duration?: number;
show: (params: {
type: ToastType;
title: string;
message: string;
duration?: number;
}) => void;
hide: () => void;
showToast: (message: string, type?: ToastType) => void;
}
export const useToast = create<ToastState>((set) => ({
visible: false,
type: "info",
title: "",
message: "",
duration: 4000,
show: ({ type, title, message, duration = 4000 }) => {
set({ visible: true, type, title, message, duration });
},
hide: () => set({ visible: false }),
showToast: (message, type = "info") => {
const title = type.charAt(0).toUpperCase() + type.slice(1);
set({ visible: true, type, title, message, duration: 4000 });
},
}));
export const toast = {
success: (title: string, message: string) =>
useToast.getState().show({ type: "success", title, message }),
error: (title: string, message: string) =>
useToast.getState().show({ type: "error", title, message }),
warning: (title: string, message: string) =>
useToast.getState().show({ type: "warning", title, message }),
info: (title: string, message: string) =>
useToast.getState().show({ type: "info", title, message }),
};

10179
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,58 +4,42 @@
"main": "expo-router/entry", "main": "expo-router/entry",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo start --android",
"ios": "expo run:ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web"
"postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~4.0.1", "@expo/metro-runtime": "~6.1.2",
"@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/native": "^7.1.28",
"@react-native-community/datetimepicker": "8.2.0", "@rn-primitives/portal": "^1.3.0",
"@react-native-google-signin/google-signin": "^16.1.2", "@rn-primitives/slot": "^1.2.0",
"@react-navigation/native": "^7.0.14", "babel-preset-expo": "^54.0.10",
"@rn-primitives/portal": "^1.1.0",
"@rn-primitives/slot": "^1.1.0",
"@simple-api/core": "^1.0.4",
"@simple-api/react-native": "^1.0.4",
"@sirou/core": "^1.1.0",
"@sirou/react-native": "^1.1.0",
"babel-preset-expo": "~11.0.15",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"expo": "~52.0.35", "expo": "~54.0.33",
"expo-camera": "~16.0.18", "expo-constants": "^18.0.13",
"expo-constants": "~17.0.7", "expo-linking": "^8.0.11",
"expo-linear-gradient": "~14.0.2", "expo-router": "^6.0.23",
"expo-linking": "~7.0.5", "expo-status-bar": "~3.0.9",
"expo-router": "~4.0.17", "lucide-react-native": "^0.575.0",
"expo-status-bar": "~2.0.1", "nativewind": "^4.2.2",
"expo-system-ui": "~4.0.9", "react": "19.1.0",
"expo-web-browser": "~14.0.2", "react-dom": "19.1.0",
"lucide-react-native": "^0.471.0", "react-native": "0.81.5",
"nativewind": "^4.1.23", "react-native-gesture-handler": "^2.30.0",
"react": "18.3.1", "react-native-reanimated": "^4.2.2",
"react-dom": "18.3.1", "react-native-safe-area-context": "^5.6.2",
"react-native": "0.76.7", "react-native-screens": "^4.23.0",
"react-native-gesture-handler": "~2.20.2", "react-native-svg": "^15.15.3",
"react-native-get-sms-android": "^2.1.0", "react-native-web": "^0.21.0",
"react-native-reanimated": "~3.16.1", "tailwind-merge": "^3.5.0",
"react-native-safe-area-context": "4.12.0", "tailwindcss-animate": "^1.0.7"
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-web": "~0.19.13",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~18.3.12", "@types/react": "~19.1.0",
"patch-package": "^8.0.1",
"prettier-plugin-tailwindcss": "^0.5.14", "prettier-plugin-tailwindcss": "^0.5.14",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.19",
"typescript": "^5.3.3", "typescript": "~5.9.2"
"@react-native-community/cli": "latest"
}, },
"private": true "private": true
} }

View File

@ -1,28 +0,0 @@
diff --git a/node_modules/react-native-css-interop/.cache/android.js b/node_modules/react-native-css-interop/.cache/android.js
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/react-native-css-interop/.cache/ios.js b/node_modules/react-native-css-interop/.cache/ios.js
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/react-native-css-interop/.cache/macos.js b/node_modules/react-native-css-interop/.cache/macos.js
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/react-native-css-interop/.cache/native.js b/node_modules/react-native-css-interop/.cache/native.js
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/react-native-css-interop/.cache/windows.js b/node_modules/react-native-css-interop/.cache/windows.js
new file mode 100644
index 0000000..e69de29
diff --git a/node_modules/react-native-css-interop/babel.js b/node_modules/react-native-css-interop/babel.js
index d84e52b..6e6fd21 100644
--- a/node_modules/react-native-css-interop/babel.js
+++ b/node_modules/react-native-css-interop/babel.js
@@ -10,7 +10,7 @@ module.exports = function () {
},
],
// Use this plugin in reanimated 4 and later
- "react-native-worklets/plugin",
+ // "react-native-worklets/plugin",
],
};
};

View File

@ -1,81 +1,73 @@
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: [ content: ['./App.tsx', './index.ts', './components/**/*.{js,jsx,ts,tsx}', './app/**/*.{js,jsx,ts,tsx}'],
"./App.tsx", presets: [require('nativewind/preset')],
"./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: "rgba(var(--border), <alpha-value>)", border: 'hsl(var(--border))',
input: "rgba(var(--input), <alpha-value>)", input: 'hsl(var(--input))',
ring: "rgba(var(--ring), <alpha-value>)", ring: 'hsl(var(--ring))',
background: "rgba(var(--background), <alpha-value>)", background: 'hsl(var(--background))',
foreground: "rgba(var(--foreground), <alpha-value>)", foreground: 'hsl(var(--foreground))',
primary: { primary: {
DEFAULT: "rgba(var(--primary), <alpha-value>)", DEFAULT: 'hsl(var(--primary))',
foreground: "rgba(var(--primary-foreground), <alpha-value>)", foreground: 'hsl(var(--primary-foreground))',
}, },
secondary: { secondary: {
DEFAULT: "rgba(var(--secondary), <alpha-value>)", DEFAULT: 'hsl(var(--secondary))',
foreground: "rgba(var(--secondary-foreground), <alpha-value>)", foreground: 'hsl(var(--secondary-foreground))',
}, },
destructive: { destructive: {
DEFAULT: "rgba(var(--destructive), <alpha-value>)", DEFAULT: 'hsl(var(--destructive))',
foreground: "rgba(var(--destructive-foreground), <alpha-value>)", foreground: 'hsl(var(--destructive-foreground))',
}, },
muted: { muted: {
DEFAULT: "rgba(var(--muted), <alpha-value>)", DEFAULT: 'hsl(var(--muted))',
foreground: "rgba(var(--muted-foreground), <alpha-value>)", foreground: 'hsl(var(--muted-foreground))',
}, },
accent: { accent: {
DEFAULT: "rgba(var(--accent), <alpha-value>)", DEFAULT: 'hsl(var(--accent))',
foreground: "rgba(var(--accent-foreground), <alpha-value>)", foreground: 'hsl(var(--accent-foreground))',
}, },
popover: { popover: {
DEFAULT: "rgba(var(--popover), <alpha-value>)", DEFAULT: 'hsl(var(--popover))',
foreground: "rgba(var(--popover-foreground), <alpha-value>)", foreground: 'hsl(var(--popover-foreground))',
}, },
card: { card: {
DEFAULT: "rgba(var(--card), <alpha-value>)", DEFAULT: 'hsl(var(--card))',
foreground: "rgba(var(--card-foreground), <alpha-value>)", foreground: 'hsl(var(--card-foreground))',
}, },
}, },
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')],
}; };