added a whole lot

This commit is contained in:
elnatansamuel25 2026-03-04 00:14:47 +03:00
parent 1b41dbd97a
commit 7162fb87e8
40 changed files with 4650 additions and 1581 deletions

3
.gitignore vendored
View File

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

View File

@ -1,5 +1,5 @@
import { Tabs, router } from "expo-router"; import { Tabs, router } from "expo-router";
import { Home, ScanLine, FileText, Wallet, History, Scan } from "@/lib/icons"; import { Home, ScanLine, FileText, Wallet, Newspaper, Scan } from "@/lib/icons";
import { Platform, View, Pressable } from "react-native"; import { Platform, View, Pressable } from "react-native";
import { ShadowWrapper } from "@/components/ShadowWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper";
@ -98,12 +98,12 @@ export default function TabsLayout() {
}} }}
/> />
<Tabs.Screen <Tabs.Screen
name="history" name="news"
options={{ options={{
tabBarLabel: "History", tabBarLabel: "News",
tabBarIcon: ({ color, focused }) => ( tabBarIcon: ({ color, focused }) => (
<View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}> <View className={focused ? "bg-primary/5 rounded-2xl p-2" : "p-2"}>
<History <Newspaper
color={color} color={color}
size={18} size={18}
strokeWidth={focused ? 2.5 : 2} strokeWidth={focused ? 2.5 : 2}

View File

@ -1,117 +0,0 @@
import React from "react";
import { View, ScrollView, Pressable } from "react-native";
import { router } from "expo-router";
import { Text } from "@/components/ui/text";
import { Card } from "@/components/ui/card";
import {
FileText,
Wallet,
ChevronRight,
TrendingUp,
TrendingDown,
Clock,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { MOCK_INVOICES, MOCK_PAYMENTS } from "@/lib/mock-data";
export default function HistoryScreen() {
// Combine and sort by date (mocking real activity)
const activity = [
...MOCK_INVOICES.map((inv) => ({
id: `inv-${inv.id}`,
type: "Invoice Sent",
title: inv.recipient,
amount: inv.amount,
date: inv.createdAt,
icon: <FileText size={16} color="#ea580c" />,
})),
...MOCK_PAYMENTS.map((pay) => ({
id: `pay-${pay.id}`,
type: "Payment Received",
title: pay.source,
amount: pay.amount,
date: pay.date,
icon: <Wallet size={16} color="#10b981" />,
})),
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return (
<ScreenWrapper className="bg-background">
<StandardHeader />
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
>
<View className="flex-row gap-2 mb-10">
<ShadowWrapper className="flex-1">
<View className="bg-card rounded-[10px] p-3">
<View className="h-8 w-8 bg-emerald-500/10 rounded-[6px] items-center justify-center mb-1">
<TrendingUp color="#10b981" size={16} />
</View>
<Text variant="muted" className="font-semibold">
Inflow
</Text>
<Text variant="h3" className="text-foreground">
$4,120
</Text>
</View>
</ShadowWrapper>
<ShadowWrapper className="flex-1">
<View className="bg-card rounded-[10px] p-3">
<View className="h-8 w-8 bg-amber-500/10 rounded-[6px] items-center justify-center mb-1">
<TrendingDown color="#f59e0b" size={16} />
</View>
<Text variant="muted" className="font-semibold">
Pending
</Text>
<Text variant="h3" className="text-foreground">
$1,540
</Text>
</View>
</ShadowWrapper>
</View>
<Text variant="h4" className="text-foreground mb-2">
Recent Activity
</Text>
<View className="gap-2">
{activity.map((item) => (
<ShadowWrapper key={item.id} level="xs">
<Card className="rounded-[6px] bg-card overflow-hidden">
<View className="flex-row items-center p-3">
<View className="bg-secondary/50 p-1 rounded-[6px] mr-4 border border-border/10">
{item.icon}
</View>
<View className="flex-1 mt-[-10px]">
<Text variant="p" className="text-foreground font-semibold">
{item.title}
</Text>
<Text variant="muted" className="text-xs font-medium">
{item.type} · {item.date}
</Text>
</View>
<View className="items-end mt-[-10px]">
<Text variant="p" className="text-foreground font-semibold">
{item.type.includes("Payment") ? "+" : ""}$
{item.amount.toLocaleString()}
</Text>
<View className="flex-row items-center gap-1">
<Clock color="#000" size={12} />
<Text className="text-[10px] text-foreground font-semibold">
Success
</Text>
</View>
</View>
</View>
</Card>
</ShadowWrapper>
))}
</View>
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -1,14 +1,15 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { View, ScrollView, Pressable } from "react-native"; import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
import { api } from "@/lib/api";
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 { EARNINGS_SUMMARY, MOCK_INVOICES } from "@/lib/mock-data"; import { useSirouRouter } from "@sirou/react-native";
import { router } from "expo-router"; import { AppRoutes } from "@/lib/routes";
import { import {
Plus, Plus,
Send, Send,
History as HistoryIcon, History as HistoryIcon,
BarChart3, Briefcase,
ChevronRight, ChevronRight,
Clock, Clock,
DollarSign, DollarSign,
@ -17,21 +18,62 @@ import {
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader"; import { StandardHeader } from "@/components/StandardHeader";
import { useAuthStore } from "@/lib/auth-store";
const statusColor: Record<string, string> = {
Waiting: "bg-amber-500/30 text-amber-600",
Paid: "bg-emerald-500/30 text-emerald-600",
Draft: "bg-secondary text-muted-foreground",
Unpaid: "bg-red-500/30 text-red-600",
};
export default function HomeScreen() { export default function HomeScreen() {
const [activeFilter, setActiveFilter] = useState("All"); 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>();
const filteredInvoices = React.useEffect(() => {
activeFilter === "All" fetchStats();
? MOCK_INVOICES }, []);
: MOCK_INVOICES.filter((inv) => inv.status === activeFilter);
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);
}
};
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
@ -61,7 +103,7 @@ export default function HomeScreen() {
<View className="mt-2 flex-row items-baseline"> <View className="mt-2 flex-row items-baseline">
<Text className="text-white text-2xl font-medium">$</Text> <Text className="text-white text-2xl font-medium">$</Text>
<Text className="ml-1 text-4xl font-bold text-white"> <Text className="ml-1 text-4xl font-bold text-white">
{EARNINGS_SUMMARY.balance.toLocaleString()} {stats.total.toLocaleString()}
</Text> </Text>
</View> </View>
@ -76,7 +118,7 @@ export default function HomeScreen() {
</Text> </Text>
</View> </View>
<Text className="text-white font-bold text-xl mt-2"> <Text className="text-white font-bold text-xl mt-2">
${EARNINGS_SUMMARY.waitingAmount.toLocaleString()} ${stats.pending.toLocaleString()}
</Text> </Text>
</View> </View>
<View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl"> <View className="flex-1 bg-white/15 rounded-[10px] p-2 border border-white/10 backdrop-blur-xl">
@ -89,7 +131,7 @@ export default function HomeScreen() {
</Text> </Text>
</View> </View>
<Text className="text-white font-bold text-xl mt-2"> <Text className="text-white font-bold text-xl mt-2">
${EARNINGS_SUMMARY.paidThisMonth.toLocaleString()} ${stats.totalRevenue.toLocaleString()}
</Text> </Text>
</View> </View>
</View> </View>
@ -101,23 +143,24 @@ export default function HomeScreen() {
{/* Circular Quick Actions Section */} {/* Circular Quick Actions Section */}
<View className="mb-4 flex-row justify-around items-center px-2"> <View className="mb-4 flex-row justify-around items-center px-2">
<QuickAction <QuickAction
icon={<Plus color="#000" size={20} strokeWidth={1.5} />} icon={<Briefcase color="#000" size={20} strokeWidth={1.5} />}
label="Scan" label="Company"
onPress={() => router.push("/(tabs)/scan")} onPress={() => nav.go("company")}
/> />
<QuickAction <QuickAction
icon={<Send color="#000" size={20} strokeWidth={1.5} />} icon={<Send color="#000" size={20} strokeWidth={1.5} />}
label="Send" label="Send"
onPress={() => router.push("/(tabs)/proforma")} onPress={() => nav.go("(tabs)/proforma")}
/> />
<QuickAction <QuickAction
icon={<HistoryIcon color="#000" size={20} strokeWidth={1.5} />} icon={<HistoryIcon color="#000" size={20} strokeWidth={1.5} />}
label="History" label="History"
onPress={() => router.push("/(tabs)/history")} onPress={() => nav.go("history")}
/> />
<QuickAction <QuickAction
icon={<BarChart3 color="#000" size={20} strokeWidth={1.5} />} icon={<Plus color="#000" size={20} strokeWidth={1.5} />}
label="Analytics" label="Create Proforma"
onPress={() => nav.go("proforma/create")}
/> />
</View> </View>
@ -126,7 +169,10 @@ export default function HomeScreen() {
<Text variant="h4" className="text-foreground tracking-tight"> <Text variant="h4" className="text-foreground tracking-tight">
Recent Activity Recent Activity
</Text> </Text>
<Pressable className="px-4 py-2 rounded-full"> <Pressable
onPress={() => nav.go("history")}
className="px-4 py-2 rounded-full"
>
<Text className="text-primary font-bold text-xs">View all</Text> <Text className="text-primary font-bold text-xs">View all</Text>
</Pressable> </Pressable>
</View> </View>
@ -138,11 +184,16 @@ export default function HomeScreen() {
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8 }} contentContainerStyle={{ gap: 8 }}
> >
{["All", "Paid", "Waiting", "Unpaid"].map((filter) => ( {["All", "Draft", "Pending", "Paid", "Overdue", "Cancelled"].map(
(filter) => (
<Pressable <Pressable
key={filter} key={filter}
onPress={() => setActiveFilter(filter)} onPress={() => setActiveFilter(filter)}
className={`rounded-[4px] px-4 py-1.5 ${activeFilter === filter ? "bg-primary" : "bg-card 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={`text-xs font-bold ${
@ -154,18 +205,22 @@ export default function HomeScreen() {
{filter} {filter}
</Text> </Text>
</Pressable> </Pressable>
))} ),
)}
</ScrollView> </ScrollView>
</View> </View>
{/* Transactions List */} {/* Transactions List */}
<View className="gap-2"> <View className="gap-2">
{filteredInvoices.length > 0 ? ( {loading ? (
filteredInvoices.map((inv) => ( <ActivityIndicator color="#ea580c" className="py-20" />
) : invoices.length > 0 ? (
invoices.map((inv) => (
<Pressable <Pressable
key={inv.id} key={inv.id}
onPress={() => router.push(`/invoices/${inv.id}`)} onPress={() => nav.go("invoices/[id]", { id: inv.id })}
> >
<ShadowWrapper level="xs">
<Card className="overflow-hidden rounded-[6px] bg-card"> <Card className="overflow-hidden rounded-[6px] bg-card">
<CardContent className="flex-row items-center py-3 px-2"> <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"> <View className="bg-secondary/40 rounded-[6px] p-2 mr-2 border border-border/10">
@ -180,13 +235,14 @@ export default function HomeScreen() {
variant="p" variant="p"
className="text-foreground font-semibold" className="text-foreground font-semibold"
> >
{inv.recipient} {inv.customerName}
</Text> </Text>
<Text <Text
variant="muted" variant="muted"
className="mt-1 text-[11px] font-medium opacity-70" className="mt-1 text-[11px] font-medium opacity-70"
> >
{inv.dueDate} · Proforma {new Date(inv.issueDate).toLocaleDateString()} ·
Proforma
</Text> </Text>
</View> </View>
<View className="items-end mt-[-20px]"> <View className="items-end mt-[-20px]">
@ -194,10 +250,18 @@ export default function HomeScreen() {
variant="p" variant="p"
className="text-foreground font-semibold" className="text-foreground font-semibold"
> >
${inv.amount.toLocaleString()} ${Number(inv.amount).toLocaleString()}
</Text> </Text>
<View <View
className={`mt-1 rounded-[5px] px-3 py-1 border border-border/50 ${statusColor[inv.status]}`} className={`mt-1 rounded-[5px] px-3 py-1 border border-border/50 ${
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"> <Text className="text-[9px] font-semibold uppercase tracking-widest">
{inv.status} {inv.status}
@ -206,6 +270,7 @@ export default function HomeScreen() {
</View> </View>
</CardContent> </CardContent>
</Card> </Card>
</ShadowWrapper>
</Pressable> </Pressable>
)) ))
) : ( ) : (

311
app/(tabs)/news.tsx Normal file
View File

@ -0,0 +1,311 @@
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 { api, newsApi } from "@/lib/api";
import { ShadowWrapper } from "@/components/ShadowWrapper";
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>();
// 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);
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">
<StandardHeader />
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 120 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#ea580c"
/>
}
>
{/* 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="bg-card/50 rounded-[12px] p-8 items-center border border-border/50">
<Text variant="muted" className="text-xs font-medium">
No latest items
</Text>
</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-20 items-center">
<Newspaper
color="#94a3b8"
size={48}
strokeWidth={1}
className="mb-4 opacity-20"
/>
<Text
variant="muted"
className="font-bold uppercase tracking-widest text-[10px]"
>
No news items available
</Text>
</View>
)}
</View>
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -1,19 +1,201 @@
import React from "react"; import React, { useState, useEffect, useCallback } from "react";
import { View, ScrollView, Pressable } from "react-native"; import {
import { router } from "expo-router"; View,
ScrollView,
Pressable,
ActivityIndicator,
FlatList,
ListRenderItem,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
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 } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { MOCK_PAYMENTS } from "@/lib/mock-data"; import { api } from "@/lib/api";
import { ScanLine, CheckCircle2, Wallet, ChevronRight } from "@/lib/icons"; import {
ScanLine,
CheckCircle2,
Wallet,
ChevronRight,
AlertTriangle,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader"; import { StandardHeader } from "@/components/StandardHeader";
import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store";
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 matched = MOCK_PAYMENTS.filter((p) => p.matched); const nav = useSirouRouter<AppRoutes>();
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);
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 (
<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 ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
@ -22,84 +204,78 @@ export default function PaymentsScreen() {
className="flex-1" className="flex-1"
contentContainerStyle={{ padding: 20, paddingBottom: 150 }} contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={({ nativeEvent }) => {
const isCloseToBottom =
nativeEvent.layoutMeasurement.height +
nativeEvent.contentOffset.y >=
nativeEvent.contentSize.height - 20;
if (isCloseToBottom) loadMore();
}}
scrollEventThrottle={400}
>
<Button
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
onPress={() => nav.go("sms-scan")}
> >
<Button className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30">
<ScanLine color="#ffffff" size={18} strokeWidth={2.5} /> <ScanLine color="#ffffff" size={18} strokeWidth={2.5} />
<Text className=" text-white text-xs font-semibold uppercase tracking-widest"> <Text className="text-white text-xs font-semibold uppercase tracking-widest ml-2">
Scan SMS Scan SMS
</Text> </Text>
</Button> </Button>
{/* Flagged Section */}
{categorized.flagged.length > 0 && (
<>
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-red-600">
Flagged Payments
</Text>
</View>
<View className="gap-2 mb-6">
{categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))}
</View>
</>
)}
{/* Pending Section */}
<View className="mb-4 flex-row items-center gap-3"> <View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-foreground"> <Text variant="h4" className="text-foreground">
Pending Match Pending Match
</Text> </Text>
</View> </View>
<View className="gap-2 mb-6">
<View className="gap-2"> {categorized.pending.length > 0 ? (
{pending.map((pay) => ( categorized.pending.map((p) => renderPaymentItem(p, "pending"))
<Pressable ) : (
key={pay.id} <Text variant="muted" className="text-center py-4">
onPress={() => router.push(`/payments/${pay.id}`)} No pending matches.
>
<Card className="rounded-[10px] bg-card overflow-hidden">
<View className="flex-row items-center p-3">
<View className="mr-2 rounded-[6px] bg-primary/10 p-2 border border-primary/5">
<Wallet color={PRIMARY} size={18} strokeWidth={2.5} />
</View>
<View className="flex-1 mt-[-15px]">
<Text variant="p" className="text-foreground font-bold">
${pay.amount.toLocaleString()}
</Text> </Text>
<Text variant="muted" className="text-xs"> )}
{pay.source} · {pay.date}
</Text>
</View>
<View className="bg-amber-500/10 px-4 py-2 rounded-[6px]">
<Text className="text-amber-700 text-[10px] font-semibold">
Match
</Text>
</View>
</View>
</Card>
</Pressable>
))}
</View> </View>
<View className="mb-4 mt-4 flex-row items-center gap-3"> {/* Reconciled Section */}
<View className="mb-4 flex-row items-center gap-3">
<Text variant="h4" className="text-foreground"> <Text variant="h4" className="text-foreground">
Reconciled Reconciled
</Text> </Text>
</View> </View>
<View className="gap-2"> <View className="gap-2">
{matched.map((pay) => ( {categorized.reconciled.length > 0 ? (
<Card categorized.reconciled.map((p) =>
key={pay.id} renderPaymentItem(p, "reconciled"),
className="rounded-[10px] bg-card overflow-hidden opacity-80" )
> ) : (
<View className="flex-row items-center p-3"> <Text variant="muted" className="text-center py-4">
<View className="mr-2 rounded-[6px] bg-emerald-500/10 p-2 border border-emerald-500/5"> No reconciled payments.
<CheckCircle2 color="#10b981" size={18} strokeWidth={2.5} />
</View>
<View className="flex-1 mt-[-15px]">
<Text variant="p" className="text-foreground font-bold">
${pay.amount.toLocaleString()}
</Text>
<Text variant="muted" className="text-xs">
{pay.source} · {pay.date}
</Text> </Text>
)}
</View> </View>
<ChevronRight
className="text-foreground" {loadingMore && (
size={18} <View className="py-4">
strokeWidth={2} <ActivityIndicator color={PRIMARY} />
color="#000"
/>
</View>
</Card>
))}
</View> </View>
)}
</ScrollView> </ScrollView>
</ScreenWrapper> </ScreenWrapper>
); );

View File

@ -1,132 +1,209 @@
import React, { useState } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { View, ScrollView, Pressable } from "react-native"; import {
View,
Pressable,
ActivityIndicator,
FlatList,
ListRenderItem,
} from "react-native";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { MOCK_PROFORMA } from "@/lib/mock-data"; import { useSirouRouter } from "@sirou/react-native";
import { router } from "expo-router"; import { AppRoutes } from "@/lib/routes";
import { import { Plus, Send, FileText, Clock } from "@/lib/icons";
Plus,
Send,
FileText,
ChevronRight,
Clock,
History,
DraftingCompass,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader"; import { StandardHeader } from "@/components/StandardHeader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import { useAuthStore } from "@/lib/auth-store";
interface ProformaItem {
id: string;
proformaNumber: string;
customerName: string;
amount: any;
currency: string;
issueDate: string;
dueDate: string;
description: string;
}
export default function ProformaScreen() { export default function ProformaScreen() {
const [activeTab, setActiveTab] = React.useState("All"); const nav = useSirouRouter<AppRoutes>();
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);
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 },
});
const newData = response.data;
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);
} 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 dateStr = new Date(item.issueDate).toLocaleDateString();
return ( return (
<ScreenWrapper className="bg-background">
<StandardHeader />
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
>
<Button
className="mb-4 h-10 rounded-[10px] bg-primary shadow-lg shadow-primary/30"
onPress={() => router.push("/proforma/create")}
>
<Plus color="white" size={18} strokeWidth={2.5} />
<Text className=" text-white text-sm font-semibold uppercase tracking-widest">
Create Proforma
</Text>
</Button>
{/* <View className="flex-row gap-4 mb-8">
<Pressable
onPress={() => setActiveTab("All")}
className={`flex-1 py-3 rounded-[10px] items-center border ${activeTab === "All" ? "bg-primary border-primary" : "bg-card border-border"}`}
>
<DraftingCompass
color={activeTab === "All" ? "white" : "#94a3b8"}
size={20}
/>
<Text
className={`mt-1 text-[10px] font-black uppercase tracking-widest ${activeTab === "All" ? "text-white" : "text-muted-foreground"}`}
>
All
</Text>
</Pressable>
<Pressable
onPress={() => setActiveTab("Pending")}
className={`flex-1 py-3 rounded-[10px] items-center border ${activeTab === "Pending" ? "bg-primary border-primary" : "bg-card border-border"}`}
>
<History
color={activeTab === "Pending" ? "white" : "#94a3b8"}
size={20}
/>
<Text
className={`mt-1 text-[10px] font-black uppercase tracking-widest ${activeTab === "Pending" ? "text-white" : "text-muted-foreground"}`}
>
Pending
</Text>
</Pressable>
</View> */}
<View className="gap-3">
{MOCK_PROFORMA.map((item) => (
<Pressable <Pressable
key={item.id} key={item.id}
onPress={() => router.push(`/proforma/${item.id}`)} onPress={() => nav.go("proforma/[id]", { id: item.id })}
className="mb-3"
> >
<Card className="rounded-[6px] bg-card overflow-hidden"> <Card className="rounded-[10px] bg-card overflow-hidden">
<View className="p-3"> <View className="p-4">
<View className="flex-row justify-between items-start"> <View className="flex-row justify-between items-start mb-3">
<View className="bg-secondary/50 p-2 rounded-[10px]"> <View className="bg-primary/10 p-2.5 rounded-[12px] border border-primary/5">
<FileText color="#000" size={18} /> <FileText color="#ea580c" size={20} strokeWidth={2.5} />
</View> </View>
<View className="bg-emerald-500/10 px-3 py-1 rounded-[6px] border border-emerald-500/20"> <View className="items-end">
<Text className="text-emerald-600 text-[10px] font-bold uppercase tracking-tighter"> <Text variant="p" className="text-foreground font-bold text-lg">
{item.sentCount} Shared {item.currency || "$"}
{amountVal?.toLocaleString()}
</Text>
<Text
variant="muted"
className="text-[10px] font-bold uppercase tracking-widest mt-0.5"
>
{item.proformaNumber}
</Text> </Text>
</View> </View>
</View> </View>
<Text variant="p" className="text-foreground font-semibold"> <Text variant="p" className="text-foreground font-bold mb-1">
{item.title} {item.customerName}
</Text> </Text>
<Text variant="muted" className="mb-4 line-clamp-2 text-xs"> {item.description && (
<Text
variant="muted"
className="text-xs line-clamp-1 mb-4 opacity-70"
>
{item.description} {item.description}
</Text> </Text>
)}
<View className="h-[1px] bg-border mb-4 opacity-50" /> <View className="h-[1px] bg-border/50 mb-4" />
<View className="flex-row justify-between items-center"> <View className="flex-row justify-between items-center">
<View className="flex-row gap-4"> <View className="flex-row items-center gap-2">
<View className="flex-row items-center gap-1.5"> <View className="p-1 bg-secondary/80 rounded-md">
<Clock <Clock color="#64748b" size={12} strokeWidth={2.5} />
className="text-muted-foreground" </View>
color="#000" <Text variant="muted" className="text-[11px] font-medium">
size={12} Issued: {dateStr}
/>
<Text variant="muted" className="text-xs">
{item.deadline}
</Text> </Text>
</View> </View>
</View>
<View className="flex-row items-center gap-3"> <Pressable
<Pressable className="bg-secondary px-2 py-1 rounded-[6px] border border-border/50 flex-row items-center gap-1"> className="bg-primary/10 px-3.5 py-1.5 rounded-full border border-primary/20 flex-row items-center gap-1.5"
<Send color="#000" size={12} /> onPress={(e) => {
<Text variant="muted" className="text-xs"> e.stopPropagation();
// Handle share
}}
>
<Send color="#ea580c" size={12} strokeWidth={2.5} />
<Text className="text-primary text-[11px] font-bold uppercase tracking-tight">
Share Share
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
</View>
</Card> </Card>
</Pressable> </Pressable>
))} );
};
return (
<ScreenWrapper className="bg-background">
<StandardHeader />
<FlatList
data={proformas}
renderItem={renderProformaItem}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 20, paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
onRefresh={onRefresh}
refreshing={refreshing}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListHeaderComponent={
<Button
className="mb-6 h-12 rounded-[14px] bg-primary shadow-lg shadow-primary/30"
onPress={() => nav.go("proforma/create")}
>
<Plus color="white" size={20} strokeWidth={3} />
<Text className="text-white text-sm font-bold uppercase tracking-widest ml-1">
Create New Proforma
</Text>
</Button>
}
ListFooterComponent={
loadingMore ? (
<ActivityIndicator color="#ea580c" className="py-4" />
) : null
}
ListEmptyComponent={
!loading ? (
<View className="py-20 items-center">
<Text variant="muted">No proformas found</Text>
</View> </View>
</ScrollView> ) : (
<View className="py-20">
<ActivityIndicator size="large" color="#ea580c" />
</View>
)
}
/>
</ScreenWrapper> </ScreenWrapper>
); );
} }

View File

@ -1,34 +1,36 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { import {
View, View,
ScrollView,
Pressable, Pressable,
Platform, Platform,
Dimensions, ActivityIndicator,
StyleSheet, Alert,
} from "react-native"; } from "react-native";
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 { X, Zap, Camera as CameraIcon } from "@/lib/icons"; import { X, Zap, Camera as CameraIcon, ScanLine } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { CameraView, useCameraPermissions } from "expo-camera"; import { CameraView, useCameraPermissions } from "expo-camera";
import { router, useNavigation } from "expo-router"; 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 { width } = Dimensions.get("window"); const NAV_BG = "#ffffff";
export default function ScanScreen() { export default function ScanScreen() {
const nav = useSirouRouter<AppRoutes>();
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [torch, setTorch] = useState(false); const [torch, setTorch] = useState(false);
const [scanning, setScanning] = useState(false);
const cameraRef = useRef<CameraView>(null);
const navigation = useNavigation(); const navigation = useNavigation();
const NAV_BG = "#ffffff"; const token = useAuthStore((s) => s.token);
// Hide tab bar when on this screen (since it's a dedicated camera view)
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({ tabBarStyle: { display: "none" } });
tabBarStyle: {
display: "none",
},
});
return () => { return () => {
navigation.setOptions({ navigation.setOptions({
tabBarStyle: { tabBarStyle: {
@ -54,34 +56,88 @@ export default function ScanScreen() {
}; };
}, [navigation]); }, [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) { if (!permission) {
// Camera permissions are still loading.
return <View className="flex-1 bg-black" />; return <View className="flex-1 bg-black" />;
} }
if (!permission.granted) { if (!permission.granted) {
// Camera permissions are not granted yet.
return ( return (
<ScreenWrapper className="bg-background items-center justify-center p-10"> <ScreenWrapper className="bg-background items-center justify-center p-10 px-16">
<View className="bg-primary/10 p-6 rounded-[24px] mb-6"> <View className="bg-primary/10 p-6 rounded-[24px] mb-6">
<CameraIcon className="text-primary" size={48} strokeWidth={1.5} /> <CameraIcon className="text-primary" size={48} strokeWidth={1.5} />
</View> </View>
<Text variant="h2" className="text-center mb-2"> <Text variant="h2" className="text-center mb-2">
Camera Access Camera Access
</Text> </Text>
<Text variant="muted" className="text-center mb-10 leading-6"> <Text variant="muted" className="text-center mb-10 leading-6 px-10">
We need your permission to use the camera to scan invoices and We need your permission to use the camera to scan invoices and
receipts automatically. receipts automatically.
</Text> </Text>
<Button <Button
className="w-full h-14 rounded-[12px] bg-primary" className="w-3/4 h-14 rounded-[12px] bg-primary px-10"
onPress={requestPermission} onPress={requestPermission}
> >
<Text className="text-white font-bold uppercase tracking-widest"> <Text className="text-white font-bold uppercase tracking-widest">
Enable Camera Enable Camera
</Text> </Text>
</Button> </Button>
<Pressable onPress={() => router.back()} className="mt-6"> <Pressable onPress={() => nav.back()} className="mt-6">
<Text className="text-muted-foreground font-bold">Go Back</Text> <Text className="text-muted-foreground font-bold">Go Back</Text>
</Pressable> </Pressable>
</ScreenWrapper> </ScreenWrapper>
@ -91,11 +147,13 @@ export default function ScanScreen() {
return ( return (
<View className="flex-1 bg-black"> <View className="flex-1 bg-black">
<CameraView <CameraView
style={StyleSheet.absoluteFill} ref={cameraRef}
style={{ flex: 1 }}
facing="back" facing="back"
enableTorch={torch} enableTorch={torch}
> >
<View className="flex-1 justify-between p-10 pt-16"> <View className="flex-1 justify-between p-10 pt-16">
{/* Top bar */}
<View className="flex-row justify-between items-center"> <View className="flex-row justify-between items-center">
<Pressable <Pressable
onPress={() => setTorch(!torch)} onPress={() => setTorch(!torch)}
@ -109,31 +167,41 @@ export default function ScanScreen() {
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => navigation.goBack()} onPress={() => nav.back()}
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20" className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
> >
<X color="white" size={24} /> <X color="white" size={24} />
</Pressable> </Pressable>
</View> </View>
{/* Scan Frame */}
<View className="items-center"> <View className="items-center">
{/* Scanning Frame */} <View className="w-72 h-72 border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
<View className="w-72 h-72 border-2 border-primary rounded-3xl border-dashed opacity-50 items-center justify-center">
<View className="w-64 h-64 border border-white/10 rounded-2xl" /> <View className="w-64 h-64 border border-white/10 rounded-2xl" />
</View> </View>
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs"> <Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
Align Invoice Align Invoice Within Frame
</Text> </Text>
</View> </View>
<View className="items-center pb-10"> {/* Capture Button */}
<View className="bg-black/40 px-6 py-3 rounded-full border border-white/10"> <View className="items-center pb-10 gap-4">
<Text className="text-white/60 text-[10px] font-black uppercase tracking-widest"> <Pressable
AI Auto-detecting... onPress={handleScan}
disabled={scanning}
className="h-20 w-20 rounded-full bg-primary items-center justify-center border-4 border-white/30"
>
{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> </Text>
</View> </View>
</View> </View>
</View>
</CameraView> </CameraView>
</View> </View>
); );

View File

@ -1,24 +1,127 @@
import React, { useEffect, useState } from "react"; 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 "@/global.css";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { View } from "react-native"; import { View, ActivityIndicator } from "react-native";
import { useRestoreTheme } from "@/lib/theme"; import { useRestoreTheme } from "@/lib/theme";
import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native";
import { routes } from "@/lib/routes";
import { authGuard, guestGuard } from "@/lib/auth-guards";
import { useAuthStore } from "@/lib/auth-store";
import { api } from "@/lib/api";
export default function RootLayout() { import { useSegments, router as expoRouter } from "expo-router";
useRestoreTheme();
function BackupGuard() {
const segments = useSegments();
const isAuthed = useAuthStore((s) => s.isAuthenticated);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
useEffect(() => { useEffect(() => {
setIsMounted(true); setIsMounted(true);
}, []); }, []);
if (!isMounted) return null; useEffect(() => {
if (!isMounted) return;
const rootSegment = segments[0];
const isPublic = rootSegment === "login" || rootSegment === "register";
if (!isAuthed && !isPublic && segments.length > 0) {
console.log("[BackupGuard] Safety redirect to /login");
expoRouter.replace("/login");
}
}, [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 expoRouter for filesystem navigation
expoRouter.replace(`/${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() {
useRestoreTheme();
const [isMounted, setIsMounted] = useState(false);
const [hasHydrated, setHasHydrated] = useState(false);
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) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#fff",
}}
>
<ActivityIndicator size="large" color="#ea580c" />
</View>
);
}
return ( return (
<SirouRouterProvider config={routes} guards={[authGuard, guestGuard]}>
<SirouBridge />
<BackupGuard />
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider> <SafeAreaProvider>
<View className="flex-1 bg-background"> <View className="flex-1 bg-background">
@ -31,11 +134,15 @@ export default function RootLayout() {
}} }}
> >
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="sms-scan" options={{ headerShown: false }} />
<Stack.Screen <Stack.Screen
name="proforma/[id]" name="proforma/[id]"
options={{ title: "Proforma request" }} options={{ title: "Proforma request" }}
/> />
<Stack.Screen name="payments/[id]" options={{ title: "Payment" }} /> <Stack.Screen
name="payments/[id]"
options={{ title: "Payment" }}
/>
<Stack.Screen <Stack.Screen
name="notifications/index" name="notifications/index"
options={{ title: "Notifications" }} options={{ title: "Notifications" }}
@ -52,18 +159,30 @@ export default function RootLayout() {
name="register" name="register"
options={{ title: "Create account", headerShown: false }} options={{ title: "Create account", headerShown: false }}
/> />
<Stack.Screen name="invoices/[id]" options={{ title: "Invoice" }} /> <Stack.Screen
<Stack.Screen name="reports/index" options={{ title: "Reports" }} /> name="invoices/[id]"
options={{ title: "Invoice" }}
/>
<Stack.Screen
name="reports/index"
options={{ title: "Reports" }}
/>
<Stack.Screen <Stack.Screen
name="documents/index" name="documents/index"
options={{ title: "Documents" }} options={{ title: "Documents" }}
/> />
<Stack.Screen name="settings" options={{ title: "Settings" }} /> <Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="profile" options={{ headerShown: false }} /> <Stack.Screen name="profile" options={{ headerShown: false }} />
<Stack.Screen
name="edit-profile"
options={{ headerShown: false }}
/>
</Stack> </Stack>
<PortalHost /> <PortalHost />
<Toast />
</View> </View>
</SafeAreaProvider> </SafeAreaProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
</SirouRouterProvider>
); );
} }

166
app/company.tsx Normal file
View File

@ -0,0 +1,166 @@
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 { api } from "@/lib/api";
import {
UserPlus,
Search,
Mail,
Phone,
ChevronRight,
Briefcase,
} 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 />
<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={isDark ? "#475569" : "#94a3b8"}
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,14 +1,16 @@
import { View, ScrollView, Pressable } from 'react-native'; import { View, ScrollView, Pressable } from "react-native";
import { router } from 'expo-router'; import { useSirouRouter } from "@sirou/react-native";
import { Text } from '@/components/ui/text'; import { AppRoutes } from "@/lib/routes";
import { Card, CardContent } from '@/components/ui/card'; import { Text } from "@/components/ui/text";
import { Button } from '@/components/ui/button'; import { Card, CardContent } from "@/components/ui/card";
import { FileText, ChevronRight, FolderOpen, Upload } from '@/lib/icons'; import { Button } from "@/components/ui/button";
import { MOCK_DOCUMENTS } from '@/lib/mock-data'; import { FileText, ChevronRight, FolderOpen, Upload } from "@/lib/icons";
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]"
@ -23,21 +25,32 @@ 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 variant="outline" className="mb-5 min-h-12 rounded-xl border-border" onPress={() => {}}> <Button
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 key={d.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"> <Card
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}>{d.name}</Text> <Text className="font-medium text-gray-900" numberOfLines={1}>
<Text className="text-muted-foreground mt-0.5 text-sm">{d.size} · {d.uploadedAt}</Text> {d.name}
</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>
@ -45,7 +58,11 @@ export default function DocumentsScreen() {
</Card> </Card>
))} ))}
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}> <Button
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>

162
app/edit-profile.tsx Normal file
View File

@ -0,0 +1,162 @@
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 bg-secondary/30 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 bg-secondary/30 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>
);
}

189
app/history.tsx Normal file
View File

@ -0,0 +1,189 @@
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 { 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-20 items-center opacity-40">
<FileText size={48} color="#94a3b8" strokeWidth={1} />
<Text variant="muted" className="mt-4 font-bold">
No activity found
</Text>
</View>
)}
</View>
)}
</ScrollView>
</ScreenWrapper>
);
}

View File

@ -1,6 +1,8 @@
import React from "react"; import React, { useState, useEffect } from "react";
import { View, ScrollView, Pressable } from "react-native"; import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
import { useLocalSearchParams, router, Stack } from "expo-router"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@ -10,52 +12,66 @@ import {
Share2, Share2,
Download, Download,
ArrowLeft, ArrowLeft,
Tag,
CreditCard,
Building2,
ExternalLink, ExternalLink,
} from "@/lib/icons"; } from "@/lib/icons";
import { MOCK_INVOICES } from "@/lib/mock-data";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper";
import { StandardHeader } from "@/components/StandardHeader";
const MOCK_ITEMS = [ import { api } from "@/lib/api";
{ import { toast } from "@/lib/toast-store";
description: "Marketing Landing Page Package",
qty: 1,
unitPrice: 1000,
total: 1000,
},
{
description: "Instagram Post Initial Design",
qty: 4,
unitPrice: 100,
total: 400,
},
];
export default function InvoiceDetailScreen() { export default function InvoiceDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const nav = useSirouRouter<AppRoutes>();
const invoice = MOCK_INVOICES.find((i) => i.id === id); const { id } = useLocalSearchParams();
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"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Invoice Details" showBack />
<View className="px-6 pt-4 flex-row justify-between items-center">
<Pressable
onPress={() => router.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<ArrowLeft color="#0f172a" size={20} />
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
Invoice Details
</Text>
<Pressable className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border">
<ExternalLink className="text-foreground" color="#000" size={18} />
</Pressable>
</View>
<ScrollView <ScrollView
className="flex-1" className="flex-1"
@ -70,12 +86,12 @@ export default function InvoiceDetailScreen() {
<FileText color="white" size={16} strokeWidth={2.5} /> <FileText color="white" size={16} strokeWidth={2.5} />
</View> </View>
<View <View
className={`rounded-[6px] px-3 py-1 ${invoice?.status === "Paid" ? "bg-emerald-500/20" : "bg-white/15"}`} className={`rounded-[6px] px-3 py-1 ${invoice.status === "PAID" ? "bg-emerald-500/20" : "bg-white/15"}`}
> >
<Text <Text
className={`text-[10px] font-bold ${invoice?.status === "Paid" ? "text-emerald-400" : "text-white"}`} className={`text-[10px] font-bold ${invoice.status === "PAID" ? "text-emerald-400" : "text-white"}`}
> >
{invoice?.status || "Pending"} {invoice.status || "Pending"}
</Text> </Text>
</View> </View>
</View> </View>
@ -84,19 +100,19 @@ export default function InvoiceDetailScreen() {
Total Amount Total Amount
</Text> </Text>
<Text variant="h3" className="text-white font-bold mb-3"> <Text variant="h3" className="text-white font-bold mb-3">
${invoice?.amount.toLocaleString() ?? "—"} ${Number(invoice.amount).toLocaleString()}
</Text> </Text>
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3"> <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="rgba(255,255,255,0.9)" size={12} />
<Text className="text-white/90 text-xs font-semibold"> <Text className="text-white/90 text-xs font-semibold">
Due {invoice?.dueDate || "—"} Due {new Date(invoice.dueDate).toLocaleDateString()}
</Text> </Text>
</View> </View>
<View className="h-3 w-[1px] bg-white/60" /> <View className="h-3 w-[1px] bg-white/60" />
<Text className="text-white/90 text-xs font-semibold"> <Text className="text-white/90 text-xs font-semibold">
#{invoice?.invoiceNumber || id} #{invoice.invoiceNumber || id}
</Text> </Text>
</View> </View>
</View> </View>
@ -107,26 +123,30 @@ export default function InvoiceDetailScreen() {
<View className="flex-row px-4 py-2"> <View className="flex-row px-4 py-2">
<View className="flex-1 flex-row items-center"> <View className="flex-1 flex-row items-center">
<View className="flex-col"> <View className="flex-col">
<Text className="text-foreground text-xs">Recipient</Text> <Text className="text-foreground text-xs opacity-60">
Recipient
</Text>
<Text <Text
variant="p" variant="p"
className="text-foreground font-semibold" className="text-foreground font-semibold"
numberOfLines={1} numberOfLines={1}
> >
{invoice?.recipient || "—"} {invoice.customerName || "—"}
</Text> </Text>
</View> </View>
</View> </View>
<View className="w-[1px] bg-border/70 mx-3" /> <View className="w-[1px] bg-border/70 mx-3" />
<View className="flex-1 flex-row items-center"> <View className="flex-1 flex-row items-center">
<View className="flex-col"> <View className="flex-col">
<Text className="text-foreground text-xs">Category</Text> <Text className="text-foreground text-xs opacity-60">
Category
</Text>
<Text <Text
variant="p" variant="p"
className="text-foreground font-semibold" className="text-foreground font-semibold"
numberOfLines={1} numberOfLines={1}
> >
Subscription General
</Text> </Text>
</View> </View>
</View> </View>
@ -136,33 +156,47 @@ export default function InvoiceDetailScreen() {
{/* Items / Billing Summary */} {/* Items / Billing Summary */}
<Card className="mb-4 bg-card rounded-[6px]"> <Card className="mb-4 bg-card rounded-[6px]">
<View className="p-4"> <View className="p-4">
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2 mb-2">
<Text variant="small" className=""> <Text
variant="small"
className="font-bold opacity-60 uppercase text-[10px] tracking-widest"
>
Billing Summary Billing Summary
</Text> </Text>
</View> </View>
{MOCK_ITEMS.map((item, i) => ( <View className="flex-row justify-between py-3 border-b border-border/70">
<View
key={i}
className={`flex-row justify-between py-3 ${i < MOCK_ITEMS.length - 1 ? "border-b border-border/70" : ""}`}
>
<View className="flex-1 pr-4"> <View className="flex-1 pr-4">
<Text <Text
variant="p" variant="p"
className="text-foreground font-semibold text-sm" className="text-foreground font-semibold text-sm"
> >
{item.description} Subtotal
</Text>
<Text variant="muted" className="text-[10px] mt-0.5">
QTY: {item.qty} · ${item.unitPrice}/unit
</Text> </Text>
</View> </View>
<Text variant="p" className="text-foreground font-bold text-sm"> <Text variant="p" className="text-foreground font-bold text-sm">
${item.total.toLocaleString()} $
{(
Number(invoice.amount) - (Number(invoice.taxAmount) || 0)
).toLocaleString()}
</Text> </Text>
</View> </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"> <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"> <Text variant="muted" className="font-semibold text-sm">
@ -172,7 +206,7 @@ export default function InvoiceDetailScreen() {
variant="h3" variant="h3"
className="text-foreground font-semibold text-xl tracking-tight" className="text-foreground font-semibold text-xl tracking-tight"
> >
${invoice?.amount.toLocaleString() || "0"} ${Number(invoice.amount).toLocaleString()}
</Text> </Text>
</View> </View>
</View> </View>
@ -181,22 +215,22 @@ export default function InvoiceDetailScreen() {
{/* Actions */} {/* Actions */}
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<Button <Button
className=" flex-1 mb-4 h-10 rounded-[6px] bg-primary shadow-lg shadow-primary/30" className=" flex-1 mb-4 h-11 rounded-[6px] bg-primary shadow-lg shadow-primary/30"
onPress={() => {}} onPress={() => {}}
> >
<Share2 color="#ffffff" size={14} strokeWidth={2.5} /> <Share2 color="#ffffff" size={14} strokeWidth={2.5} />
<Text className=" text-white text-xs font-semibold uppercase tracking-widest"> <Text className="ml-2 text-white text-[11px] font-bold uppercase tracking-widest">
Share Share
</Text> </Text>
</Button> </Button>
<ShadowWrapper> <ShadowWrapper>
<Button <Button
className=" flex-1 mb-4 h-10 rounded-[10px] bg-card" className=" flex-1 mb-4 h-11 rounded-[6px] bg-card border border-border"
onPress={() => {}} onPress={() => {}}
> >
<Download color="#000" size={14} strokeWidth={2.5} /> <Download color="#0f172a" size={14} strokeWidth={2.5} />
<Text className="text-black text-xs font-semibold uppercase tracking-widest"> <Text className="ml-2 text-foreground text-[11px] font-bold uppercase tracking-widest">
Download PDF PDF
</Text> </Text>
</Button> </Button>
</ShadowWrapper> </ShadowWrapper>

View File

@ -1,40 +1,220 @@
import { View, ScrollView, Pressable } from 'react-native'; import React, { useState } from "react";
import { router } from 'expo-router'; import {
import { Text } from '@/components/ui/text'; View,
import { Button } from '@/components/ui/button'; ScrollView,
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; Pressable,
import { Mail, ArrowLeft } from '@/lib/icons'; TextInput,
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 } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { useAuthStore } from "@/lib/auth-store";
import * as Linking from "expo-linking";
import { api, BASE_URL } from "@/lib/api";
import { useColorScheme } from "nativewind";
import { toast } from "@/lib/toast-store";
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 [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, and refresh token
setAuth(response.user, response.accessToken, response.refreshToken);
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 () => {
setLoading(true);
try {
// Hit api.auth.google directly — that's it
const response = await api.auth.google();
setAuth(response.user, response.accessToken, response.refreshToken);
toast.success("Welcome!", "Signed in with Google.");
nav.go("(tabs)");
} catch (err: any) {
console.error("[Login] Google Login Error:", err);
toast.error(
"Google Login Failed",
err.message || "An unexpected error occurred.",
);
} finally {
setLoading(false);
}
};
return ( return (
<ScrollView <ScreenWrapper className="bg-background">
className="flex-1 bg-[#f5f5f5]" <KeyboardAvoidingView
contentContainerStyle={{ padding: 24, paddingVertical: 48 }} behavior={Platform.OS === "ios" ? "padding" : "height"}
showsVerticalScrollIndicator={false} className="flex-1"
> >
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text> <ScrollView
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white"> className="flex-1"
<CardHeader> contentContainerStyle={{ padding: 24, paddingTop: 60 }}
<CardTitle className="text-lg">Sign in</CardTitle> keyboardShouldPersistTaps="handled"
<CardDescription className="mt-1">Use the same account as the web app.</CardDescription> >
</CardHeader> {/* Logo / Branding */}
<CardContent className="gap-3"> <View className="items-center mb-10">
<Button className="min-h-12 rounded-xl bg-primary"> <Text variant="h2" className="mt-6 font-bold text-foreground">
<Mail color="#ffffff" size={20} strokeWidth={2} /> Login
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text> </Text>
</Button> <Text variant="muted" className="mt-2 text-center">
<Button variant="outline" className="min-h-12 rounded-xl border-border"> Sign in to manage your tickets & invoices
<Text className="font-medium text-gray-700">Continue with Google</Text> </Text>
</Button> </View>
</CardContent>
</Card> {/* Form */}
<Pressable onPress={() => router.push('/register')} className="mt-4"> <View className="gap-5">
<Text className="text-center text-primary font-medium">Create account</Text> <View>
<Text variant="small" className="font-semibold mb-2 ml-1">
Email or Phone Number
</Text>
<View className="flex-row items-center bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
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> </Pressable>
<Button variant="ghost" className="mt-4 rounded-xl" onPress={() => router.back()}> </View>
<ArrowLeft color="#71717a" size={20} strokeWidth={2} /> </View>
<Text className="ml-2 font-medium text-muted-foreground">Back</Text>
<Button
className="h-14 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>
{/* Social / Other */}
<View className="mt-12">
<View className="flex-row items-center mb-8">
<View className="flex-1 h-[1px] bg-border" />
<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-14 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>
</View>
<Pressable
className="mt-10 items-center justify-center py-2"
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>
</ScreenWrapper>
); );
} }

View File

@ -1,31 +1,62 @@
import { View, ScrollView, Pressable } from 'react-native'; import { View, ScrollView, Pressable } from "react-native";
import { router } from 'expo-router'; import { useSirouRouter } from "@sirou/react-native";
import { Text } from '@/components/ui/text'; import { AppRoutes } from "@/lib/routes";
import { Card, CardContent } from '@/components/ui/card'; import { Text } from "@/components/ui/text";
import { Bell, Settings, ChevronRight } from '@/lib/icons'; import { Card, CardContent } from "@/components/ui/card";
import { Bell, Settings, ChevronRight } from "@/lib/icons";
const MOCK_NOTIFICATIONS = [ const MOCK_NOTIFICATIONS = [
{ id: '1', title: 'Invoice reminder', body: 'Invoice #2 to Robin Murray is due in 2 days.', time: '2h ago', read: false }, {
{ id: '2', title: 'Payment received', body: 'Payment of $500 received for Invoice #4.', time: '1d ago', read: true }, id: "1",
{ id: '3', title: 'Proforma submission', body: 'Vendor A submitted a quote for Marketing Landing Page.', time: '2d ago', read: true }, title: "Invoice reminder",
body: "Invoice #2 to Robin Murray is due in 2 days.",
time: "2h ago",
read: false,
},
{
id: "2",
title: "Payment received",
body: "Payment of $500 received for Invoice #4.",
time: "1d ago",
read: true,
},
{
id: "3",
title: "Proforma submission",
body: "Vendor A submitted a quote for Marketing Landing Page.",
time: "2d ago",
read: true,
},
]; ];
export default function NotificationsScreen() { export default function NotificationsScreen() {
const nav = useSirouRouter<AppRoutes>();
return ( return (
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}> <ScrollView
className="flex-1 bg-background"
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
>
<View className="mb-4 flex-row items-center justify-between"> <View className="mb-4 flex-row items-center justify-between">
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
<Bell color="#18181b" size={22} strokeWidth={2} /> <Bell color="#18181b" size={22} strokeWidth={2} />
<Text className="text-xl font-semibold text-gray-900">Notifications</Text> <Text className="text-xl font-semibold text-gray-900">
Notifications
</Text>
</View> </View>
<Pressable className="flex-row items-center gap-1" onPress={() => router.push('/notifications/settings')}> <Pressable
className="flex-row items-center gap-1"
onPress={() => nav.go("notifications/settings")}
>
<Settings color="#ea580c" size={18} strokeWidth={2} /> <Settings color="#ea580c" size={18} strokeWidth={2} />
<Text className="text-primary font-medium">Settings</Text> <Text className="text-primary font-medium">Settings</Text>
</Pressable> </Pressable>
</View> </View>
{MOCK_NOTIFICATIONS.map((n) => ( {MOCK_NOTIFICATIONS.map((n) => (
<Card key={n.id} className={`mb-2 ${!n.read ? 'border-primary/30' : ''}`}> <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-gray-900">{n.title}</Text> <Text className="font-semibold text-gray-900">{n.title}</Text>
<Text className="text-muted-foreground mt-1 text-sm">{n.body}</Text> <Text className="text-muted-foreground mt-1 text-sm">{n.body}</Text>

View File

@ -1,18 +1,23 @@
import { View, ScrollView, Switch } from 'react-native'; import { View, ScrollView, Switch } from "react-native";
import { router } from 'expo-router'; import { useSirouRouter } from "@sirou/react-native";
import { useState } from 'react'; import { AppRoutes } from "@/lib/routes";
import { Text } from '@/components/ui/text'; import { useState } from "react";
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, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function NotificationSettingsScreen() { export default function NotificationSettingsScreen() {
const nav = useSirouRouter<AppRoutes>();
const [invoiceReminders, setInvoiceReminders] = useState(true); const [invoiceReminders, setInvoiceReminders] = useState(true);
const [daysBeforeDue, setDaysBeforeDue] = useState(2); const [daysBeforeDue, setDaysBeforeDue] = useState(2);
const [newsAlerts, setNewsAlerts] = useState(true); const [newsAlerts, setNewsAlerts] = useState(true);
const [reportReady, setReportReady] = useState(true); const [reportReady, setReportReady] = useState(true);
return ( return (
<ScrollView className="flex-1 bg-background" contentContainerStyle={{ padding: 16, paddingBottom: 32 }}> <ScrollView
className="flex-1 bg-background"
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
>
<Card className="mb-4"> <Card className="mb-4">
<CardHeader> <CardHeader>
<CardTitle>Notification settings</CardTitle> <CardTitle>Notification settings</CardTitle>
@ -20,7 +25,10 @@ export default function NotificationSettingsScreen() {
<CardContent className="gap-4"> <CardContent className="gap-4">
<View className="flex-row items-center justify-between"> <View className="flex-row items-center justify-between">
<Text className="text-gray-900">Invoice reminders</Text> <Text className="text-gray-900">Invoice reminders</Text>
<Switch value={invoiceReminders} onValueChange={setInvoiceReminders} /> <Switch
value={invoiceReminders}
onValueChange={setInvoiceReminders}
/>
</View> </View>
<View className="flex-row items-center justify-between"> <View className="flex-row items-center justify-between">
<Text className="text-gray-900">News & announcements</Text> <Text className="text-gray-900">News & announcements</Text>
@ -33,7 +41,7 @@ export default function NotificationSettingsScreen() {
</CardContent> </CardContent>
</Card> </Card>
<Button variant="outline" onPress={() => router.back()}> <Button variant="outline" onPress={() => nav.back()}>
<Text className="font-medium">Back</Text> <Text className="font-medium">Back</Text>
</Button> </Button>
</ScrollView> </ScrollView>

View File

@ -1,5 +1,7 @@
import { View, ScrollView, Pressable } from "react-native"; import { View, ScrollView, Pressable } from "react-native";
import { useLocalSearchParams, router, Stack } from "expo-router"; import { useSirouRouter, useSirouParams } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack } from "expo-router";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@ -7,7 +9,8 @@ import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
export default function PaymentDetailScreen() { export default function PaymentDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const nav = useSirouRouter<AppRoutes>();
const { id } = useSirouParams<AppRoutes, "payments/[id]">();
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
@ -15,7 +18,7 @@ export default function PaymentDetailScreen() {
<View className="px-6 pt-4 flex-row justify-between items-center"> <View className="px-6 pt-4 flex-row justify-between items-center">
<Pressable <Pressable
onPress={() => router.back()} onPress={() => nav.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
> >
<ArrowLeft color="#0f172a" size={20} /> <ArrowLeft color="#0f172a" size={20} />

View File

@ -9,7 +9,8 @@ import {
TouchableOpacity, TouchableOpacity,
TouchableWithoutFeedback, TouchableWithoutFeedback,
} from "react-native"; } from "react-native";
import { router } from "expo-router"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { import {
ArrowLeft, ArrowLeft,
@ -29,6 +30,11 @@ import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper";
import { useColorScheme } from "nativewind"; import { useColorScheme } from "nativewind";
import { saveTheme, AppTheme } from "@/lib/theme"; import { saveTheme, AppTheme } from "@/lib/theme";
import { useAuthStore } from "@/lib/auth-store";
// ── Constants ─────────────────────────────────────────────────────
const AVATAR_FALLBACK_BASE =
"https://ui-avatars.com/api/?background=ea580c&color=fff&name=";
// ── Theme bottom sheet ──────────────────────────────────────────── // ── Theme bottom sheet ────────────────────────────────────────────
const THEME_OPTIONS = [ const THEME_OPTIONS = [
@ -168,6 +174,8 @@ function MenuItem({
// ── Screen ──────────────────────────────────────────────────────── // ── Screen ────────────────────────────────────────────────────────
export default function ProfileScreen() { export default function ProfileScreen() {
const nav = useSirouRouter<AppRoutes>();
const { user, logout } = useAuthStore();
const { setColorScheme, colorScheme } = useColorScheme(); const { setColorScheme, colorScheme } = useColorScheme();
const [notifications, setNotifications] = useState(true); const [notifications, setNotifications] = useState(true);
const [themeSheetVisible, setThemeSheetVisible] = useState(false); const [themeSheetVisible, setThemeSheetVisible] = useState(false);
@ -184,7 +192,7 @@ export default function ProfileScreen() {
{/* Header */} {/* Header */}
<View className="px-6 pt-4 flex-row justify-between items-center"> <View className="px-6 pt-4 flex-row justify-between items-center">
<Pressable <Pressable
onPress={() => router.back()} onPress={() => nav.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
> >
<ArrowLeft color="#0f172a" size={20} /> <ArrowLeft color="#0f172a" size={20} />
@ -194,10 +202,10 @@ export default function ProfileScreen() {
</Text> </Text>
{/* Edit Profile shortcut */} {/* Edit Profile shortcut */}
<Pressable <Pressable
onPress={() => {}} onPress={() => nav.go("edit-profile")}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
> >
<User className="text-foreground" size={18} /> <User className="text-foreground" color="#000" size={18} />
</Pressable> </Pressable>
</View> </View>
@ -211,19 +219,21 @@ export default function ProfileScreen() {
> >
{/* Avatar */} {/* Avatar */}
<View className="items-center mb-8"> <View className="items-center mb-8">
<View className="h-20 w-20 rounded-full border-2 border-border overflow-hidden bg-muted mb-3"> <View className="h-20 w-20 rounded-full overflow-hidden bg-muted mb-3">
<Image <Image
source={{ source={{
uri: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=300&h=300", uri:
user?.avatar ||
`${AVATAR_FALLBACK_BASE}${encodeURIComponent(`${user?.firstName} ${user?.lastName}`)}`,
}} }}
className="h-full w-full" className="h-full w-full"
/> />
</View> </View>
<Text variant="h4" className="text-foreground font-bold"> <Text variant="h4" className="text-foreground">
Ms. Charlotte {user?.firstName} {user?.lastName}
</Text> </Text>
<Text variant="muted" className="text-sm mt-0.5"> <Text variant="muted" className="text-sm mt-0.5">
charlotte@example.com {user?.email}
</Text> </Text>
</View> </View>
@ -301,7 +311,7 @@ export default function ProfileScreen() {
icon={<LogOut color="#ef4444" size={17} />} icon={<LogOut color="#ef4444" size={17} />}
label="Log Out" label="Log Out"
destructive destructive
onPress={() => {}} onPress={logout}
right={null} right={null}
isLast isLast
/> />

View File

@ -1,6 +1,8 @@
import React from "react"; import React, { useState, useEffect } from "react";
import { View, ScrollView, Pressable } from "react-native"; import { View, ScrollView, Pressable, ActivityIndicator } from "react-native";
import { useLocalSearchParams, router, Stack } from "expo-router"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack, useLocalSearchParams } from "expo-router";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@ -8,104 +10,169 @@ import {
ArrowLeft, ArrowLeft,
DraftingCompass, DraftingCompass,
Clock, Clock,
Tag,
Send, Send,
ExternalLink, ExternalLink,
ChevronRight, ChevronRight,
CheckCircle2, CheckCircle2,
} from "@/lib/icons"; } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
const MOCK_ITEMS = [ import { api } from "@/lib/api";
{ import { toast } from "@/lib/toast-store";
description: "Marketing Landing Page Package",
qty: 1,
unitPrice: 1000,
total: 1000,
},
{
description: "Instagram Post Initial Design",
qty: 4,
unitPrice: 100,
total: 400,
},
];
const MOCK_SUBTOTAL = 1400;
const MOCK_TAX = 140;
const MOCK_TOTAL = 1540;
export default function ProformaDetailScreen() { export default function ProformaDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const nav = useSirouRouter<AppRoutes>();
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");
} 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"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<View className="px-6 pt-4 flex-row justify-between items-center"> {/* Header */}
<Pressable <StandardHeader title="Proforma" showBack />
onPress={() => router.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<ArrowLeft color="#0f172a" size={20} />
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
Proforma
</Text>
<Pressable className="h-9 w-9 rounded-[6px] bg-card items-center justify-center border border-border">
<ExternalLink className="text-foreground" color="#000" size={17} />
</Pressable>
</View>
<ScrollView <ScrollView
className="flex-1" className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 120 }} contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<Card className=" overflow-hidden rounded-[6px] border-0 bg-primary"> {/* Blue Summary Card */}
<Card className="overflow-hidden rounded-[6px] border-0 bg-primary mb-4">
<View className="p-5"> <View className="p-5">
<View className="flex-row items-center justify-between mb-3"> <View className="flex-row items-center justify-between mb-3">
<View className="bg-white/20 p-1.5 rounded-[6px]"> <View className="bg-white/20 p-1.5 rounded-[6px]">
<DraftingCompass color="white" size={16} strokeWidth={2.5} /> <DraftingCompass color="white" size={16} strokeWidth={2.5} />
</View> </View>
<View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10"> <View className="bg-amber-500/20 px-3 py-1 rounded-[6px] border border-white/10">
<Text className={`text-[10px] font-bold text-white`}> <Text className="text-[10px] font-bold text-white uppercase tracking-widest">
Open Request ACTIVE
</Text> </Text>
</View> </View>
</View> </View>
<Text variant="small" className="text-white/70 mb-0.5"> <Text variant="small" className="text-white/70 mb-0.5">
Target Package Customer: {proforma.customerName}
</Text> </Text>
<Text variant="h3" className="text-white font-bold mb-3"> <Text variant="h3" className="text-white font-bold mb-3">
Marketing Landing Page {proforma.description || "Proforma Request"}
</Text> </Text>
<View className="flex-row items-center gap-3 border-t border-white/40 pt-3"> <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">
<Clock color="rgba(255,255,255,0.9)" size={12} />
<Text className="text-white/90 text-xs font-semibold"> <Text className="text-white/90 text-xs font-semibold">
Expires in 5 days Due {new Date(proforma.dueDate).toLocaleDateString()}
</Text> </Text>
</View> </View>
<View className="h-3 w-[1px] bg-white/60" /> <View className="h-3 w-[1px] bg-white/60" />
<Text className="text-white/90 text-xs font-semibold"> <Text className="text-white/90 text-xs font-semibold">
REQ-{id || "002"} {proforma.proformaNumber}
</Text> </Text>
</View> </View>
</View> </View>
</Card> </Card>
{/* Customer Info Strip (Added for functionality while keeping style) */}
<Card className="bg-card rounded-[6px] mb-4">
<View className="flex-row px-4 py-2">
<View className="flex-1 flex-row items-center">
<View className="flex-col">
<Text className="text-foreground text-[10px] opacity-60 uppercase font-bold">
Email
</Text>
<Text
variant="p"
className="text-foreground font-semibold text-xs"
numberOfLines={1}
>
{proforma.customerEmail || "N/A"}
</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-[10px] opacity-60 uppercase font-bold">
Phone
</Text>
<Text
variant="p"
className="text-foreground font-semibold text-xs"
numberOfLines={1}
>
{proforma.customerPhone || "N/A"}
</Text>
</View>
</View>
</View>
</Card>
{/* Line Items Card */}
<Card className="bg-card rounded-[6px] mb-4"> <Card className="bg-card rounded-[6px] mb-4">
<View className="p-4"> <View className="p-4">
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2 mb-2">
<Text variant="small" className="font-semibold"> <Text
variant="small"
className="font-bold uppercase tracking-widest text-[10px] opacity-60"
>
Line Items Line Items
</Text> </Text>
</View> </View>
{MOCK_ITEMS.map((item, i) => ( {proforma.items?.map((item: any, i: number) => (
<View <View
key={i} key={item.id || i}
className={`flex-row justify-between py-3 ${i < MOCK_ITEMS.length - 1 ? "border-b border-border/40" : ""}`} className={`flex-row justify-between py-3 ${i < proforma.items.length - 1 ? "border-b border-border/40" : ""}`}
> >
<View className="flex-1 pr-4"> <View className="flex-1 pr-4">
<Text <Text
@ -115,11 +182,12 @@ export default function ProformaDetailScreen() {
{item.description} {item.description}
</Text> </Text>
<Text variant="muted" className="text-[10px] mt-0.5"> <Text variant="muted" className="text-[10px] mt-0.5">
{item.qty} × ${item.unitPrice.toLocaleString()} {item.quantity} × {proforma.currency}{" "}
{Number(item.unitPrice).toLocaleString()}
</Text> </Text>
</View> </View>
<Text variant="p" className="text-foreground font-bold text-sm"> <Text variant="p" className="text-foreground font-bold text-sm">
${item.total.toLocaleString()} {proforma.currency} {Number(item.total).toLocaleString()}
</Text> </Text>
</View> </View>
))} ))}
@ -133,59 +201,76 @@ export default function ProformaDetailScreen() {
Subtotal Subtotal
</Text> </Text>
<Text variant="p" className="text-foreground font-bold text-sm"> <Text variant="p" className="text-foreground font-bold text-sm">
${MOCK_SUBTOTAL.toLocaleString()} {proforma.currency} {subtotal.toLocaleString()}
</Text> </Text>
</View> </View>
{Number(proforma.taxAmount) > 0 && (
<View className="flex-row justify-between"> <View className="flex-row justify-between">
<Text <Text
variant="p" variant="p"
className="text-foreground font-semibold text-sm" className="text-foreground font-semibold text-sm"
> >
Tax (10%) Tax
</Text> </Text>
<Text variant="p" className="text-foreground font-bold text-sm"> <Text
${MOCK_TAX.toLocaleString()} variant="p"
className="text-foreground font-bold text-sm"
>
{proforma.currency}{" "}
{Number(proforma.taxAmount).toLocaleString()}
</Text> </Text>
</View> </View>
)}
{Number(proforma.discountAmount) > 0 && (
<View className="flex-row justify-between">
<Text
variant="p"
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"> <View className="flex-row justify-between items-center mt-1">
<Text variant="p" className="text-foreground font-semibold"> <Text variant="p" className="text-foreground font-bold">
Estimated Total Total Amount
</Text> </Text>
<Text <Text
variant="h4" variant="h4"
className="text-foreground font-bold tracking-tight" className="text-foreground font-bold tracking-tight"
> >
${MOCK_TOTAL.toLocaleString()} {proforma.currency} {Number(proforma.amount).toLocaleString()}
</Text> </Text>
</View> </View>
</View> </View>
</View> </View>
</Card> </Card>
<Text variant="h4" className="text-foreground mb-2"> {/* Notes Section (New) */}
Recent Submissions {proforma.notes && (
<Card className="bg-card rounded-[6px] mb-4">
<View className="p-4">
<Text
variant="small"
className="font-bold uppercase tracking-widest text-[10px] opacity-60 mb-2"
>
Additional Notes
</Text> </Text>
<Card className="bg-card rounded-[6px] mb-6">
<Pressable className="flex-row items-center p-3">
<View className="bg-secondary h-9 w-9 rounded-[6px] items-center justify-center mr-3 border border-border/50">
<CheckCircle2 className="text-muted-foreground" size={16} />
</View>
<View className="flex-1 mt-[-10px]">
<Text <Text
variant="p" variant="p"
className="text-foreground font-semibold text-sm" className="text-foreground font-medium text-xs leading-5"
> >
Vendor A $1,450 {proforma.notes}
</Text>
<Text variant="muted" className="text-xs mt-0.5">
Submitted 2 hours ago
</Text> </Text>
</View> </View>
<ChevronRight className="text-muted-foreground/50" size={16} />
</Pressable>
</Card> </Card>
)}
{/* Actions */}
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<Button <Button
className="flex-1 h-11 rounded-[6px] bg-primary" className="flex-1 h-11 rounded-[6px] bg-primary"
@ -198,7 +283,7 @@ export default function ProformaDetailScreen() {
</Button> </Button>
<Button <Button
className="flex-1 h-11 rounded-[6px] bg-card border border-border" className="flex-1 h-11 rounded-[6px] bg-card border border-border"
onPress={() => router.back()} onPress={() => nav.back()}
> >
<Text className="text-foreground font-semibold text-[11px] uppercase tracking-widest"> <Text className="text-foreground font-semibold text-[11px] uppercase tracking-widest">
Back Back

View File

@ -1,25 +1,38 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { import {
View, View,
ScrollView, ScrollView,
Pressable, Pressable,
TextInput, TextInput,
StyleSheet, StyleSheet,
Platform, ActivityIndicator,
} from "react-native"; } from "react-native";
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 { ArrowLeft, Trash2, Send, Plus } from "@/lib/icons"; import {
ArrowLeft,
ArrowRight,
Trash2,
Send,
Plus,
Calendar,
ChevronDown,
CalendarSearch,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { router, Stack } from "expo-router"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { Stack } from "expo-router";
import { useColorScheme } from "nativewind"; import { useColorScheme } from "nativewind";
import { ShadowWrapper } from "@/components/ShadowWrapper"; 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";
type Item = { id: number; description: string; qty: string; price: string }; type Item = { id: number; description: string; qty: string; price: string };
// All TextInput styles are native StyleSheet — NO className on TextInput
// NativeWind className on TextInput causes focus loop because it re-processes
// styles each render and resets the responder chain.
const S = StyleSheet.create({ const S = StyleSheet.create({
input: { input: {
height: 44, height: 44,
@ -71,7 +84,10 @@ function Field({
const c = useInputColors(); const c = useInputColors();
return ( return (
<View style={flex != null ? { flex } : undefined}> <View style={flex != null ? { flex } : undefined}>
<Text variant="muted" className="font-semibold text-xs mb-1.5"> <Text
variant="small"
className="font-semibold text-[10px] uppercase tracking-wider mb-1.5"
>
{label} {label}
</Text> </Text>
<TextInput <TextInput
@ -92,17 +108,52 @@ function Field({
); );
} }
const CURRENCIES = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"];
export default function CreateProformaScreen() { export default function CreateProformaScreen() {
const [company, setCompany] = useState(""); const nav = useSirouRouter<AppRoutes>();
const [project, setProject] = useState(""); const [loading, setLoading] = useState(false);
const [validity, setValidity] = useState("");
const [terms, setTerms] = useState(""); // 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[]>([ const [items, setItems] = useState<Item[]>([
{ id: 1, description: "", qty: "1", price: "" }, { id: 1, description: "", qty: "1", price: "" },
]); ]);
const c = useInputColors(); 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) => const updateField = (id: number, field: keyof Item, value: string) =>
setItems((prev) => setItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)), prev.map((item) => (item.id === id ? { ...item, [field]: value } : item)),
@ -119,78 +170,196 @@ export default function CreateProformaScreen() {
setItems((prev) => prev.filter((item) => item.id !== id)); setItems((prev) => prev.filter((item) => item.id !== id));
}; };
const total = items.reduce( const subtotal = items.reduce(
(sum, item) => (sum, item) =>
sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0), sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0),
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);
}
};
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader title="Create Proforma" showBack />
<View className="px-6 pt-4 flex-row justify-between items-center">
<Pressable
onPress={() => router.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<ArrowLeft color="#0f172a" size={20} />
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
New Proforma
</Text>
<View className="w-9" />
</View>
<ScrollView <ScrollView
className="flex-1" className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 140 }} contentContainerStyle={{ padding: 16, paddingBottom: 30 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
{/* Recipient */} {/* Header Info */}
<Label>Recipient</Label> <Label>General Information</Label>
<ShadowWrapper> <ShadowWrapper>
<View className="bg-card rounded-[6px] p-4 mb-5 gap-4"> <View className="bg-card rounded-[6px] p-4 mb-5 gap-4">
<Field <Field
label="Company / Name" label="Proforma Number"
value={company} value={proformaNumber}
onChangeText={setCompany} onChangeText={setProformaNumber}
placeholder="e.g. Acme Corp" placeholder="e.g. PROF-2024-001"
/> />
<Field <Field
label="Project Title" label="Project Description"
value={project} value={description}
onChangeText={setProject} onChangeText={setDescription}
placeholder="e.g. Website Redesign" placeholder="e.g. Web Development Services"
/> />
</View> </View>
</ShadowWrapper> </ShadowWrapper>
{/* Terms */} {/* Recipient */}
<Label>Terms & Validity</Label> <Label>Customer Details</Label>
<ShadowWrapper> <ShadowWrapper>
<View className="bg-card rounded-[6px] p-4 mb-5"> <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"> <View className="flex-row gap-4">
<Field <Field
label="Validity (days)" label="Email"
value={validity} value={customerEmail}
onChangeText={setValidity} onChangeText={setCustomerEmail}
placeholder="30" placeholder="billing@acme.com"
numeric
flex={1} flex={1}
/> />
<Field <Field
label="Payment Terms" label="Phone"
value={terms} value={customerPhone}
onChangeText={setTerms} onChangeText={setCustomerPhone}
placeholder="e.g. 50% upfront" placeholder="+251..."
flex={2} flex={1}
/> />
</View> </View>
</View> </View>
</ShadowWrapper> </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 */} {/* Items */}
<View className="flex-row items-center justify-between mb-3"> <View className="flex-row items-center justify-between mb-3">
<Label noMargin>Billable Items</Label> <Label noMargin>Billable Items</Label>
@ -207,12 +376,12 @@ export default function CreateProformaScreen() {
<View className="gap-3 mb-5"> <View className="gap-3 mb-5">
{items.map((item, index) => ( {items.map((item, index) => (
<ShadowWrapper> <ShadowWrapper key={item.id}>
<View key={item.id} className="bg-card rounded-[6px] p-4"> <View className="bg-card rounded-[6px] p-4">
<View className="flex-row justify-between items-center mb-3"> <View className="flex-row justify-between items-center mb-3">
<Text <Text
variant="muted" variant="muted"
className="text-[12px] font-bold uppercase tracking-wide" className="text-[10px] font-bold uppercase tracking-wide opacity-50"
> >
Item {index + 1} Item {index + 1}
</Text> </Text>
@ -223,89 +392,43 @@ export default function CreateProformaScreen() {
)} )}
</View> </View>
<Text <Field
variant="muted" label="Description"
className="text-[11px] font-semibold mb-1.5" placeholder="e.g. UI Design"
>
Description
</Text>
<TextInput
style={[
S.input,
{
backgroundColor: c.bg,
borderColor: c.border,
color: c.text,
marginBottom: 12,
},
]}
placeholder="e.g. Web Design Package"
placeholderTextColor={c.placeholder}
value={item.description} value={item.description}
onChangeText={(v) => updateField(item.id, "description", v)} onChangeText={(v) => updateField(item.id, "description", v)}
autoCorrect={false}
autoCapitalize="none"
returnKeyType="next"
/> />
<View className="flex-row gap-3"> <View className="flex-row gap-3 mt-4">
<View className="flex-1"> <Field
<Text label="Qty"
variant="muted"
className="text-[11px] font-semibold mb-1.5"
>
Qty
</Text>
<TextInput
style={[
S.inputCenter,
{
backgroundColor: c.bg,
borderColor: c.border,
color: c.text,
},
]}
placeholder="1" placeholder="1"
placeholderTextColor={c.placeholder} numeric
keyboardType="numeric" center
value={item.qty} value={item.qty}
onChangeText={(v) => updateField(item.id, "qty", v)} onChangeText={(v) => updateField(item.id, "qty", v)}
returnKeyType="next" flex={1}
/> />
</View> <Field
<View className="flex-[2]"> label="Price"
<Text
variant="muted"
className="text-[11px] font-semibold mb-1.5"
>
Unit Price ($)
</Text>
<TextInput
style={[
S.input,
{
backgroundColor: c.bg,
borderColor: c.border,
color: c.text,
},
]}
placeholder="0.00" placeholder="0.00"
placeholderTextColor={c.placeholder} numeric
keyboardType="numeric"
value={item.price} value={item.price}
onChangeText={(v) => updateField(item.id, "price", v)} onChangeText={(v) => updateField(item.id, "price", v)}
returnKeyType="done" flex={2}
/> />
</View>
<View className="flex-1 items-end justify-end pb-1"> <View className="flex-1 items-end justify-end pb-1">
<Text variant="muted" className="text-[10px]"> <Text
variant="muted"
className="text-[9px] uppercase font-bold opacity-40"
>
Total Total
</Text> </Text>
<Text <Text
variant="p" variant="p"
className="text-foreground font-bold text-sm" className="text-foreground font-bold text-sm"
> >
$ {currency}
{( {(
(parseFloat(item.qty) || 0) * (parseFloat(item.qty) || 0) *
(parseFloat(item.price) || 0) (parseFloat(item.price) || 0)
@ -319,13 +442,74 @@ export default function CreateProformaScreen() {
</View> </View>
{/* Summary */} {/* Summary */}
<View className="border border-border/60 rounded-[6px] p-4 bg-secondary/10 mb-6"> <Label>Totals & Taxes</Label>
<View className="flex-row justify-between items-center mb-4"> <ShadowWrapper>
<Text variant="muted" className="font-semibold text-sm"> <View className="bg-card rounded-[6px] p-4 mb-5 gap-3">
Estimated Total <View className="flex-row justify-between items-center">
<Text variant="muted" className="text-xs font-medium">
Subtotal
</Text> </Text>
<Text variant="h4" className="text-foreground font-semibold"> <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={c.placeholder}
value={notes}
onChangeText={setNotes}
multiline
/>
</View>
</ShadowWrapper>
{/* Footer */}
<View className="border border-border/60 rounded-[12px] p-5 bg-primary/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", { {total.toLocaleString("en-US", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
@ -335,22 +519,83 @@ export default function CreateProformaScreen() {
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<Button <Button
variant="outline" variant="outline"
className="flex-1 h-11 rounded-[6px] border-border bg-card" className="flex-1 h-12 rounded-[6px] border-border bg-card"
onPress={() => router.back()} onPress={() => nav.back()}
disabled={loading}
> >
<Text className="text-foreground font-semibold text-[11px] uppercase tracking-widest"> <Text className="text-foreground font-bold text-xs uppercase tracking-tighter">
Cancel Discard
</Text> </Text>
</Button> </Button>
<Button className="flex-1 h-11 rounded-[6px] bg-primary"> <Button
<Send color="white" size={14} strokeWidth={2.5} /> className="flex-1 h-12 rounded-[6px] bg-primary"
<Text className=" text-white font-bold text-[11px] uppercase tracking-widest"> onPress={handleSubmit}
Create & Share disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" size="small" />
) : (
<>
<Send color="white" size={16} strokeWidth={2.5} />
<Text className="text-white font-bold text-xs uppercase tracking-tighter">
Create Proforma
</Text> </Text>
</>
)}
</Button> </Button>
</View> </View>
</View> </View>
</ScrollView> </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> </ScreenWrapper>
); );
} }
@ -363,7 +608,10 @@ function Label({
noMargin?: boolean; noMargin?: boolean;
}) { }) {
return ( return (
<Text variant="muted" className={`font-semibold ${noMargin ? "" : "mb-3"}`}> <Text
variant="small"
className={`text-[14px] font-semibold ${noMargin ? "" : "mb-3"}`}
>
{children} {children}
</Text> </Text>
); );

View File

@ -1,40 +1,230 @@
import { View, ScrollView, Pressable } from 'react-native'; import React, { useState } from "react";
import { router } from 'expo-router'; import {
import { Text } from '@/components/ui/text'; View,
import { Button } from '@/components/ui/button'; ScrollView,
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; Pressable,
import { Mail, ArrowLeft, UserPlus } from '@/lib/icons'; TextInput,
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,
} 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";
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 [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 (
<ScrollView <ScreenWrapper className="bg-background">
className="flex-1 bg-[#f5f5f5]" <KeyboardAvoidingView
contentContainerStyle={{ padding: 24, paddingVertical: 48 }} behavior={Platform.OS === "ios" ? "padding" : "height"}
showsVerticalScrollIndicator={false} className="flex-1"
> >
<Text className="mb-8 text-center text-2xl font-bold text-gray-900">Yaltopia Tickets</Text> <ScrollView
<Card className="mb-5 overflow-hidden rounded-2xl border border-border bg-white"> className="flex-1"
<CardHeader> contentContainerStyle={{ padding: 24, paddingBottom: 60 }}
<CardTitle className="text-lg">Create account</CardTitle> keyboardShouldPersistTaps="handled"
<CardDescription className="mt-1">Register with the same account format as the web app.</CardDescription> >
</CardHeader> <View className="mb-10 mt-10">
<CardContent className="gap-3"> <Text
<Button className="min-h-12 rounded-xl bg-primary"> variant="h2"
<UserPlus color="#ffffff" size={20} strokeWidth={2} /> className="mt-6 font-bold text-foreground text-center"
<Text className="ml-2 text-primary-foreground font-medium">Email & password</Text> >
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="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
<TextInput
className="text-foreground"
placeholder="John"
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
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="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
<TextInput
className="text-foreground"
placeholder="Doe"
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
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-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>
<Button variant="outline" className="min-h-12 rounded-xl border-border"> </View>
<Text className="font-medium text-gray-700">Continue with Google</Text>
</Button> <Pressable
</CardContent> className="mt-10 items-center justify-center py-2"
</Card> onPress={() => nav.go("login")}
<Pressable onPress={() => router.push('/login')} className="mt-2"> >
<Text className="text-center text-primary font-medium">Already have an account? Sign in</Text> <Text className="text-muted-foreground">
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>
</ScreenWrapper>
); );
} }

View File

@ -1,14 +1,16 @@
import { View, ScrollView, Pressable } from 'react-native'; import { View, ScrollView, Pressable } from "react-native";
import { 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 { Text } from "@/components/ui/text";
import { Card, CardContent } from '@/components/ui/card'; import { Button } from "@/components/ui/button";
import { FileText, Download, ChevronRight, BarChart3 } from '@/lib/icons'; import { Card, CardContent } from "@/components/ui/card";
import { MOCK_REPORTS } from '@/lib/mock-data'; import { FileText, Download, ChevronRight, BarChart3 } from "@/lib/icons";
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]"
@ -24,7 +26,10 @@ export default function ReportsScreen() {
</Text> </Text>
{MOCK_REPORTS.map((r) => ( {MOCK_REPORTS.map((r) => (
<Card key={r.id} className="mb-2.5 overflow-hidden rounded-xl border border-border bg-white"> <Card
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">
@ -32,8 +37,12 @@ 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">{r.period}</Text> <Text className="text-muted-foreground mt-0.5 text-sm">
<Text className="text-muted-foreground mt-0.5 text-xs">Generated {r.generatedAt}</Text> {r.period}
</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">
@ -46,7 +55,11 @@ export default function ReportsScreen() {
</Card> </Card>
))} ))}
<Button variant="outline" className="mt-4 rounded-xl border-border" onPress={() => router.back()}> <Button
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,13 +1,15 @@
import { View, ScrollView, Pressable } from 'react-native'; import { View, ScrollView, Pressable } from "react-native";
import { 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 { Text } from "@/components/ui/text";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from "@/components/ui/button";
import { Settings, Bell, Globe, ChevronRight, Info } from '@/lib/icons'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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]"
@ -26,7 +28,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={() => router.push('/notifications/settings')} onPress={() => nav.go("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} />
@ -60,11 +62,16 @@ 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 see swagger.json and README for integration. API: Invoices, Proforma, Payments, Reports, Documents, Notifications
see swagger.json and README for integration.
</Text> </Text>
</View> </View>
<Button variant="outline" className="mt-6 rounded-xl border-border" onPress={() => router.back()}> <Button
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>

256
app/sms-scan.tsx Normal file
View File

@ -0,0 +1,256 @@
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 {
SmsAndroid = require("react-native-get-sms-android").default;
} catch (_) {}
// 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;
}
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(
"Package Missing",
"Run: npm install react-native-get-sms-android",
);
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 5 minutes
const fiveMinutesAgo = Date.now() - 5 * 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 getBankLabel = (sms: SmsMessage) => {
const text = (sms.body + sms.address).toUpperCase();
if (text.includes("CBE")) return { name: "CBE", color: "#16a34a" };
if (text.includes("DASHEN"))
return { name: "Dashen Bank", color: "#1d4ed8" };
if (text.includes("127") || text.includes("TELEBIRR"))
return { name: "Telebirr", color: "#7c3aed" };
return { name: "Bank", color: "#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-3">
{messages.map((sms) => {
const bank = getBankLabel(sms);
return (
<Card key={sms._id} className="rounded-[12px] bg-card p-4">
<View className="flex-row items-center justify-between mb-2">
<View
className="px-3 py-1 rounded-full"
style={{ backgroundColor: bank.color + "20" }}
>
<Text
className="text-xs font-bold"
style={{ color: bank.color }}
>
{bank.name}
</Text>
</View>
<Text variant="muted" className="text-xs">
{formatTime(sms.date)}
</Text>
</View>
<Text className="text-foreground text-sm leading-5">
{sms.body}
</Text>
<Text variant="muted" className="text-xs mt-2">
From: {sms.address}
</Text>
</Card>
);
})}
</View>
</ScrollView>
</ScreenWrapper>
);
}

257
app/user/create.tsx Normal file
View File

@ -0,0 +1,257 @@
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";
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="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
<TextInput
className="text-foreground"
placeholder="First Name"
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
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="bg-secondary/30 rounded-xl px-4 border border-border h-12 justify-center">
<TextInput
className="text-foreground"
placeholder="Last Name"
placeholderTextColor={isDark ? "#475569" : "#94a3b8"}
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
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 bg-secondary/30 rounded-xl px-4 border border-border h-12"
>
<ShieldCheck size={18} color={isDark ? "#94a3b8" : "#64748b"} />
<Text className="flex-1 ml-3 text-foreground 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 bg-secondary/30 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={isDark ? "#475569" : "#94a3b8"}
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>
);
}

BIN
assets/google-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

160
components/CalendarGrid.tsx Normal file
View File

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

123
components/PickerModal.tsx Normal file
View File

@ -0,0 +1,123 @@
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,28 +1,49 @@
import React from "react"; import { View, Image, Pressable, useColorScheme } from "react-native";
import { View, Image, Pressable } from "react-native";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Bell } from "@/lib/icons"; import { ArrowLeft, Bell } from "@/lib/icons";
import { ShadowWrapper } from "@/components/ShadowWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper";
import { MOCK_USER } from "@/lib/mock-data"; import { useAuthStore } from "@/lib/auth-store";
import { router } from "expo-router"; import { router } from "expo-router";
export function StandardHeader() { interface StandardHeaderProps {
title?: string;
showBack?: boolean;
}
export function StandardHeader({ title, showBack }: StandardHeaderProps) {
const user = useAuthStore((state) => state.user);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
// 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 ( return (
<View className="px-5 pt-4 pb-2 flex-row justify-between items-center bg-background"> <View className="px-5 pt-4 pb-2 flex-row justify-between items-center bg-background">
<View className="flex-row items-center gap-3"> <View className="flex-1 flex-row items-center gap-3">
<ShadowWrapper level="xs"> {showBack && (
<Pressable
onPress={() => router.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 <Pressable
onPress={() => router.push("/profile")} onPress={() => router.push("/profile")}
className="h-[40px] w-[40px] rounded-full border-2 border-primary/20 overflow-hidden" className="h-[40px] w-[40px] rounded-full overflow-hidden"
> >
<Image <Image source={{ uri: avatarUri }} className="h-full w-full" />
source={{
uri: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&q=80&w=200&h=200",
}}
className="h-full w-full"
/>
</Pressable> </Pressable>
</ShadowWrapper> </View>
<View> <View>
<Text <Text
variant="muted" variant="muted"
@ -31,16 +52,32 @@ export function StandardHeader() {
Welcome back, Welcome back,
</Text> </Text>
<Text variant="h4" className="text-foreground leading-tight"> <Text variant="h4" className="text-foreground leading-tight">
{MOCK_USER.name} {user?.firstName + " " + user?.lastName || "User"}
</Text> </Text>
</View> </View>
</View> </View>
) : (
<View className="flex-1 items-center mr-10">
<Text variant="h4" className="text-foreground font-semibold">
{title}
</Text>
</View>
)}
</View>
{!title && (
<ShadowWrapper level="xs"> <ShadowWrapper level="xs">
<Pressable className="rounded-full p-2.5 border border-border"> <Pressable className="rounded-full p-2.5 border border-border">
<Bell color="#000" size={20} strokeWidth={2} /> <Bell
color={isDark ? "#f1f5f9" : "#0f172a"}
size={20}
strokeWidth={2}
/>
</Pressable> </Pressable>
</ShadowWrapper> </ShadowWrapper>
)}
{title && <View className="w-0" />}
</View> </View>
); );
} }

147
components/Toast.tsx Normal file
View File

@ -0,0 +1,147 @@
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: "#f0fdf4",
border: "#22c55e",
icon: <CheckCircle2 size={24} color="#22c55e" />,
},
info: {
bg: "#f0f9ff",
border: "#0ea5e9",
icon: <Lightbulb size={24} color="#0ea5e9" />,
},
warning: {
bg: "#fffbeb",
border: "#f59e0b",
icon: <AlertTriangle size={24} color="#f59e0b" />,
},
error: {
bg: "#fef2f2",
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,
backgroundColor: variant.bg,
borderColor: variant.border,
},
animatedStyle,
]}
className="border-2 rounded-2xl shadow-xl 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,
},
});

119
lib/api-middlewares.ts Normal file
View File

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

97
lib/api.ts Normal file
View File

@ -0,0 +1,97 @@
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/api/v1/";
/**
* Central API client using simple-api
*/
export const api = createApi({
baseUrl: BASE_URL,
middleware: [
createLoggerMiddleware(),
createTransformerMiddleware(),
refreshMiddleware,
],
services: {
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/register-owner" },
refresh: { method: "POST", path: "auth/refresh" },
logout: { method: "POST", path: "auth/logout" },
profile: { method: "GET", path: "auth/profile" },
google: { method: "GET", path: "auth/google" },
callback: { method: "GET", path: "auth/google/callback" },
},
},
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" },
},
},
proforma: {
middleware: [authMiddleware],
endpoints: {
getAll: { method: "GET", path: "proforma" },
getById: { method: "GET", path: "proforma/:id" },
create: { method: "POST", path: "proforma" },
},
},
},
});
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;

46
lib/auth-guards.ts Normal file
View File

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

89
lib/auth-store.ts Normal file
View File

@ -0,0 +1,89 @@
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;
isAuthenticated: boolean;
setAuth: (user: User, token: string, refreshToken?: string) => void;
logout: () => Promise<void>;
updateUser: (user: Partial<User>) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, token, refreshToken = undefined) => {
console.log("[AuthStore] Setting auth state:", {
hasUser: !!user,
hasToken: !!token,
hasRefreshToken: !!refreshToken,
});
set({
user,
token,
refreshToken: refreshToken ?? null,
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,
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

@ -54,4 +54,22 @@ export {
HelpCircle, HelpCircle,
ArrowUpRight, ArrowUpRight,
Lock, 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"; } from "lucide-react-native";

133
lib/routes.ts Normal file
View File

@ -0,0 +1,133 @@
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 },
},
// Stacks
"proforma/[id]": {
path: "/proforma/:id",
params: { id: "string" },
guards: ["auth"],
meta: { requiresAuth: true },
},
"proforma/create": {
path: "/proforma/create",
guards: ["auth"],
meta: { requiresAuth: true },
},
"payments/[id]": {
path: "/payments/:id",
params: { id: "string" },
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" },
},
"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;

46
lib/toast-store.ts Normal file
View File

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

790
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,14 @@
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~4.0.1", "@expo/metro-runtime": "~4.0.1",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/datetimepicker": "8.2.0",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@rn-primitives/portal": "^1.1.0", "@rn-primitives/portal": "^1.1.0",
"@rn-primitives/slot": "^1.1.0", "@rn-primitives/slot": "^1.1.0",
"@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", "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",
@ -26,19 +31,22 @@
"expo-router": "~4.0.17", "expo-router": "~4.0.17",
"expo-status-bar": "~2.0.1", "expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.9", "expo-system-ui": "~4.0.9",
"expo-web-browser": "~14.0.2",
"lucide-react-native": "^0.471.0", "lucide-react-native": "^0.471.0",
"nativewind": "^4.1.23", "nativewind": "^4.1.23",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.7", "react-native": "0.76.7",
"react-native-gesture-handler": "~2.20.2", "react-native-gesture-handler": "~2.20.2",
"react-native-get-sms-android": "^2.1.0",
"react-native-reanimated": "~3.16.1", "react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0", "react-native-svg": "15.8.0",
"react-native-web": "~0.19.13", "react-native-web": "~0.19.13",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~18.3.12", "@types/react": "~18.3.12",