diff --git a/.gitignore b/.gitignore index d914c32..c0e3f14 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.* # generated native folders /ios /android + +*.apk +*.aab diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 2c479c5..e366d7f 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ 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 { ShadowWrapper } from "@/components/ShadowWrapper"; @@ -98,12 +98,12 @@ export default function TabsLayout() { }} /> ( - ({ - id: `inv-${inv.id}`, - type: "Invoice Sent", - title: inv.recipient, - amount: inv.amount, - date: inv.createdAt, - icon: , - })), - ...MOCK_PAYMENTS.map((pay) => ({ - id: `pay-${pay.id}`, - type: "Payment Received", - title: pay.source, - amount: pay.amount, - date: pay.date, - icon: , - })), - ].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - - return ( - - - - - - - - - - - Inflow - - - $4,120 - - - - - - - - - - Pending - - - $1,540 - - - - - - - Recent Activity - - - - {activity.map((item) => ( - - - - - {item.icon} - - - - {item.title} - - - {item.type} · {item.date} - - - - - {item.type.includes("Payment") ? "+" : ""}$ - {item.amount.toLocaleString()} - - - - - Success - - - - - - - ))} - - - - ); -} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f14867d..d8d6a04 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,14 +1,15 @@ 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 { Card, CardContent } from "@/components/ui/card"; -import { EARNINGS_SUMMARY, MOCK_INVOICES } from "@/lib/mock-data"; -import { router } from "expo-router"; +import { useSirouRouter } from "@sirou/react-native"; +import { AppRoutes } from "@/lib/routes"; import { Plus, Send, History as HistoryIcon, - BarChart3, + Briefcase, ChevronRight, Clock, DollarSign, @@ -17,21 +18,62 @@ import { import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper"; import { StandardHeader } from "@/components/StandardHeader"; - -const statusColor: Record = { - 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", -}; +import { useAuthStore } from "@/lib/auth-store"; export default function HomeScreen() { const [activeFilter, setActiveFilter] = useState("All"); + const [stats, setStats] = useState({ + total: 0, + paid: 0, + pending: 0, + overdue: 0, + totalRevenue: 0, + }); + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(false); + const nav = useSirouRouter(); - const filteredInvoices = - activeFilter === "All" - ? MOCK_INVOICES - : MOCK_INVOICES.filter((inv) => inv.status === activeFilter); + React.useEffect(() => { + fetchStats(); + }, []); + + React.useEffect(() => { + fetchInvoices(); + }, [activeFilter]); + + const fetchStats = async () => { + const { isAuthenticated } = useAuthStore.getState(); + if (!isAuthenticated) return; + + try { + const data = await api.invoices.stats(); + setStats(data); + } catch (e) { + console.error("[HomeScreen] Failed to fetch stats:", e); + } + }; + + const fetchInvoices = async () => { + const { isAuthenticated } = useAuthStore.getState(); + if (!isAuthenticated) return; + + setLoading(true); + try { + const statusParam = + activeFilter === "All" ? undefined : activeFilter.toUpperCase(); + const response = await api.invoices.getAll({ + query: { + limit: 5, + status: statusParam, + }, + }); + setInvoices(response.data || []); + } catch (e) { + console.error("[HomeScreen] Failed to fetch invoices:", e); + } finally { + setLoading(false); + } + }; return ( @@ -61,7 +103,7 @@ export default function HomeScreen() { $ - {EARNINGS_SUMMARY.balance.toLocaleString()} + {stats.total.toLocaleString()} @@ -76,7 +118,7 @@ export default function HomeScreen() { - ${EARNINGS_SUMMARY.waitingAmount.toLocaleString()} + ${stats.pending.toLocaleString()} @@ -89,7 +131,7 @@ export default function HomeScreen() { - ${EARNINGS_SUMMARY.paidThisMonth.toLocaleString()} + ${stats.totalRevenue.toLocaleString()} @@ -101,23 +143,24 @@ export default function HomeScreen() { {/* Circular Quick Actions Section */} } - label="Scan" - onPress={() => router.push("/(tabs)/scan")} + icon={} + label="Company" + onPress={() => nav.go("company")} /> } label="Send" - onPress={() => router.push("/(tabs)/proforma")} + onPress={() => nav.go("(tabs)/proforma")} /> } label="History" - onPress={() => router.push("/(tabs)/history")} + onPress={() => nav.go("history")} /> } - label="Analytics" + icon={} + label="Create Proforma" + onPress={() => nav.go("proforma/create")} /> @@ -126,7 +169,10 @@ export default function HomeScreen() { Recent Activity - + nav.go("history")} + className="px-4 py-2 rounded-full" + > View all @@ -138,74 +184,93 @@ export default function HomeScreen() { showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }} > - {["All", "Paid", "Waiting", "Unpaid"].map((filter) => ( - setActiveFilter(filter)} - className={`rounded-[4px] px-4 py-1.5 ${activeFilter === filter ? "bg-primary" : "bg-card border border-border"}`} - > - ( + setActiveFilter(filter)} + className={`rounded-[4px] px-4 py-1.5 ${ activeFilter === filter - ? "text-white" - : "text-muted-foreground" + ? "bg-primary" + : "bg-card border border-border" }`} > - {filter} - - - ))} + + {filter} + + + ), + )} {/* Transactions List */} - {filteredInvoices.length > 0 ? ( - filteredInvoices.map((inv) => ( + {loading ? ( + + ) : invoices.length > 0 ? ( + invoices.map((inv) => ( router.push(`/invoices/${inv.id}`)} + onPress={() => nav.go("invoices/[id]", { id: inv.id })} > - - - - - - - - {inv.recipient} - - - {inv.dueDate} · Proforma - - - - - ${inv.amount.toLocaleString()} - - - - {inv.status} + + + + + + + + + {inv.customerName} + + + {new Date(inv.issueDate).toLocaleDateString()} · + Proforma - - - + + + ${Number(inv.amount).toLocaleString()} + + + + {inv.status} + + + + + + )) ) : ( diff --git a/app/(tabs)/news.tsx b/app/(tabs)/news.tsx new file mode 100644 index 0000000..3374226 --- /dev/null +++ b/app/(tabs)/news.tsx @@ -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(); + + // Safe accessor to handle initialization race conditions + const getNewsApi = () => { + if (newsApi) return newsApi; + return api.news; + }; + + // Latest News State + const [latestNews, setLatestNews] = useState([]); + const [loadingLatest, setLoadingLatest] = useState(true); + + // All News State + const [allNews, setAllNews] = useState([]); + 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 }) => ( + + + + + + + + + {item.category} + + + + {new Date(item.publishedAt).toLocaleDateString()} + + + + {item.title} + + + + + + Tap to read more + + + + + + + + + + ); + + const NewsItem = ({ item }: { item: NewsItem }) => ( + + + + + + + + {item.category} + + + + {item.title} + + + {item.content} + + + + + + + {new Date(item.publishedAt).toLocaleDateString()} + + + + + + + + ); + + return ( + + + + + } + > + {/* Latest News Section */} + + + Latest News + + + {loadingLatest ? ( + + ) : latestNews.length > 0 ? ( + + {latestNews.map((item) => ( + + ))} + + ) : ( + + + No latest items + + + )} + + + {/* All News Section */} + + + All News + + + {loadingAll ? ( + + ) : allNews.length > 0 ? ( + <> + {allNews.map((item) => ( + + ))} + {hasMore && ( + + {loadingMore ? ( + + ) : ( + + Load More + + )} + + )} + + ) : ( + + + + No news items available + + + )} + + + + ); +} diff --git a/app/(tabs)/payments.tsx b/app/(tabs)/payments.tsx index 0cd0599..ce0a05d 100644 --- a/app/(tabs)/payments.tsx +++ b/app/(tabs)/payments.tsx @@ -1,19 +1,201 @@ -import React from "react"; -import { View, ScrollView, Pressable } from "react-native"; -import { router } from "expo-router"; +import React, { useState, useEffect, useCallback } from "react"; +import { + 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 { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { MOCK_PAYMENTS } from "@/lib/mock-data"; -import { ScanLine, CheckCircle2, Wallet, ChevronRight } from "@/lib/icons"; +import { api } from "@/lib/api"; +import { + ScanLine, + CheckCircle2, + Wallet, + ChevronRight, + AlertTriangle, +} from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { StandardHeader } from "@/components/StandardHeader"; +import { toast } from "@/lib/toast-store"; +import { useAuthStore } from "@/lib/auth-store"; 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() { - const matched = MOCK_PAYMENTS.filter((p) => p.matched); - const pending = MOCK_PAYMENTS.filter((p) => !p.matched); + const nav = useSirouRouter(); + const [payments, setPayments] = useState([]); + 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 ( + nav.go("payments/[id]", { id: pay.id })} + className="mb-2" + > + + + + {isFlagged ? ( + + ) : isReconciled ? ( + + ) : ( + + )} + + + + {pay.currency || "$"} + {amountValue?.toLocaleString()} + + + {pay.paymentMethod} · {dateStr} + + + {isFlagged ? ( + + + Flagged + + + ) : !isReconciled ? ( + + + Match + + + ) : ( + + )} + + + + ); + }; + + if (loading && page === 1) { + return ( + + + + + + + ); + } return ( @@ -22,84 +204,78 @@ export default function PaymentsScreen() { className="flex-1" contentContainerStyle={{ padding: 20, paddingBottom: 150 }} showsVerticalScrollIndicator={false} + onScroll={({ nativeEvent }) => { + const isCloseToBottom = + nativeEvent.layoutMeasurement.height + + nativeEvent.contentOffset.y >= + nativeEvent.contentSize.height - 20; + if (isCloseToBottom) loadMore(); + }} + scrollEventThrottle={400} > - + {/* Flagged Section */} + {categorized.flagged.length > 0 && ( + <> + + + Flagged Payments + + + + {categorized.flagged.map((p) => renderPaymentItem(p, "flagged"))} + + + )} + + {/* Pending Section */} Pending Match - - - {pending.map((pay) => ( - router.push(`/payments/${pay.id}`)} - > - - - - - - - - ${pay.amount.toLocaleString()} - - - {pay.source} · {pay.date} - - - - - Match - - - - - - ))} + + {categorized.pending.length > 0 ? ( + categorized.pending.map((p) => renderPaymentItem(p, "pending")) + ) : ( + + No pending matches. + + )} - + {/* Reconciled Section */} + Reconciled - - {matched.map((pay) => ( - - - - - - - - ${pay.amount.toLocaleString()} - - - {pay.source} · {pay.date} - - - - - - ))} + {categorized.reconciled.length > 0 ? ( + categorized.reconciled.map((p) => + renderPaymentItem(p, "reconciled"), + ) + ) : ( + + No reconciled payments. + + )} + + {loadingMore && ( + + + + )} ); diff --git a/app/(tabs)/proforma.tsx b/app/(tabs)/proforma.tsx index 29cac2d..3071bca 100644 --- a/app/(tabs)/proforma.tsx +++ b/app/(tabs)/proforma.tsx @@ -1,132 +1,209 @@ -import React, { useState } from "react"; -import { View, ScrollView, Pressable } from "react-native"; +import React, { useState, useEffect, useCallback } from "react"; +import { + View, + Pressable, + ActivityIndicator, + FlatList, + ListRenderItem, +} from "react-native"; import { Text } from "@/components/ui/text"; import { Card } from "@/components/ui/card"; -import { MOCK_PROFORMA } from "@/lib/mock-data"; -import { router } from "expo-router"; -import { - Plus, - Send, - FileText, - ChevronRight, - Clock, - History, - DraftingCompass, -} from "@/lib/icons"; +import { useSirouRouter } from "@sirou/react-native"; +import { AppRoutes } from "@/lib/routes"; +import { Plus, Send, FileText, Clock } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; -import { ShadowWrapper } from "@/components/ShadowWrapper"; import { StandardHeader } from "@/components/StandardHeader"; 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() { - const [activeTab, setActiveTab] = React.useState("All"); + const nav = useSirouRouter(); + const [proformas, setProformas] = useState([]); + 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 = ({ item }) => { + const amountVal = + typeof item.amount === "object" ? item.amount.value : item.amount; + const dateStr = new Date(item.issueDate).toLocaleDateString(); + + return ( + nav.go("proforma/[id]", { id: item.id })} + className="mb-3" + > + + + + + + + + + {item.currency || "$"} + {amountVal?.toLocaleString()} + + + {item.proformaNumber} + + + + + + {item.customerName} + + {item.description && ( + + {item.description} + + )} + + + + + + + + + + Issued: {dateStr} + + + + { + e.stopPropagation(); + // Handle share + }} + > + + + Share + + + + + + + ); + }; return ( - item.id} contentContainerStyle={{ padding: 20, paddingBottom: 150 }} showsVerticalScrollIndicator={false} - > - - - {/* - setActiveTab("All")} - className={`flex-1 py-3 rounded-[10px] items-center border ${activeTab === "All" ? "bg-primary border-primary" : "bg-card border-border"}`} + onRefresh={onRefresh} + refreshing={refreshing} + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListHeaderComponent={ + + } + ListFooterComponent={ + loadingMore ? ( + + ) : null + } + ListEmptyComponent={ + !loading ? ( + + No proformas found + + ) : ( + + + + ) + } + /> ); } diff --git a/app/(tabs)/scan.tsx b/app/(tabs)/scan.tsx index a7bb760..195a28b 100644 --- a/app/(tabs)/scan.tsx +++ b/app/(tabs)/scan.tsx @@ -1,34 +1,36 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { View, - ScrollView, Pressable, Platform, - Dimensions, - StyleSheet, + ActivityIndicator, + Alert, } from "react-native"; import { Text } from "@/components/ui/text"; 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 { 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() { + const nav = useSirouRouter(); const [permission, requestPermission] = useCameraPermissions(); const [torch, setTorch] = useState(false); + const [scanning, setScanning] = useState(false); + const cameraRef = useRef(null); 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(() => { - navigation.setOptions({ - tabBarStyle: { - display: "none", - }, - }); + navigation.setOptions({ tabBarStyle: { display: "none" } }); return () => { navigation.setOptions({ tabBarStyle: { @@ -54,34 +56,88 @@ export default function ScanScreen() { }; }, [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) { - // Camera permissions are still loading. return ; } if (!permission.granted) { - // Camera permissions are not granted yet. return ( - + Camera Access - + We need your permission to use the camera to scan invoices and receipts automatically. - router.back()} className="mt-6"> + nav.back()} className="mt-6"> Go Back @@ -91,11 +147,13 @@ export default function ScanScreen() { return ( + {/* Top bar */} setTorch(!torch)} @@ -109,29 +167,39 @@ export default function ScanScreen() { navigation.goBack()} + onPress={() => nav.back()} className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20" > + {/* Scan Frame */} - {/* Scanning Frame */} - + - Align Invoice + Align Invoice Within Frame - - - - AI Auto-detecting... - - + {/* Capture Button */} + + + {scanning ? ( + + ) : ( + + )} + + + {scanning ? "Extracting Data..." : "Tap to Scan"} + diff --git a/app/_layout.tsx b/app/_layout.tsx index 6a4d6a1..08a54cf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,69 +1,188 @@ import React, { useEffect, useState } from "react"; -import "../global.css"; import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { PortalHost } from "@rn-primitives/portal"; import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { Toast } from "@/components/Toast"; +import "@/global.css"; 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 { 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() { - useRestoreTheme(); +import { useSegments, router as expoRouter } from "expo-router"; + +function BackupGuard() { + const segments = useSegments(); + const isAuthed = useAuthStore((s) => s.isAuthenticated); const [isMounted, setIsMounted] = useState(false); useEffect(() => { 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 ( + + + + ); + } return ( - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/app/company.tsx b/app/company.tsx new file mode 100644 index 0000000..814920a --- /dev/null +++ b/app/company.tsx @@ -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(); + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + + const [workers, setWorkers] = useState([]); + 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 ( + + + + + + {/* Search Bar */} + + + + + + + + {/* Worker List Header */} + + + Workers ({filteredWorkers.length}) + + + + {loading ? ( + + + + ) : ( + + } + contentContainerStyle={{ paddingBottom: 100 }} + > + {filteredWorkers.length > 0 ? ( + filteredWorkers.map((worker) => ( + + + + + + {worker.firstName?.[0]} + {worker.lastName?.[0]} + + + + + + {worker.firstName} {worker.lastName} + + + + {worker.role || "WORKER"} + + + + + + + + + )) + ) : ( + + + + No workers found + + + )} + + )} + + + {/* Floating Action Button */} + 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" + > + + + + ); +} diff --git a/app/documents/index.tsx b/app/documents/index.tsx index 47503b5..d8ca80f 100644 --- a/app/documents/index.tsx +++ b/app/documents/index.tsx @@ -1,14 +1,16 @@ -import { View, ScrollView, Pressable } from 'react-native'; -import { router } from 'expo-router'; -import { Text } from '@/components/ui/text'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { FileText, ChevronRight, FolderOpen, Upload } from '@/lib/icons'; -import { MOCK_DOCUMENTS } from '@/lib/mock-data'; +import { View, ScrollView, Pressable } 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 { Button } from "@/components/ui/button"; +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() { + const nav = useSirouRouter(); return ( - {MOCK_DOCUMENTS.map((d) => ( - + - {d.name} - {d.size} · {d.uploadedAt} + + {d.name} + + + {d.size} · {d.uploadedAt} + @@ -45,7 +58,11 @@ export default function DocumentsScreen() { ))} - diff --git a/app/edit-profile.tsx b/app/edit-profile.tsx new file mode 100644 index 0000000..e4be4b0 --- /dev/null +++ b/app/edit-profile.tsx @@ -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(); + 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 ( + + {/* Header */} + + nav.back()} + className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" + > + + + + Edit Profile + + {/* Spacer */} + + + + + {/* First Name */} + + + First Name + + + + + {firstName.trim().length > 0 && ( + + )} + + + + {/* Last Name */} + + + Last Name + + + + + {lastName.trim().length > 0 && ( + + )} + + + + + + + nav.back()} + className="h-10 border border-border items-center justify-center" + disabled={loading} + > + + Cancel + + + + + + + ); +} diff --git a/app/history.tsx b/app/history.tsx new file mode 100644 index 0000000..a46ae68 --- /dev/null +++ b/app/history.tsx @@ -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(); + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState({ totalRevenue: 0, pending: 0 }); + const [invoices, setInvoices] = useState([]); + + 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 ( + + + + + + + + + + + + + Total Inflow + + + ${stats.totalRevenue.toLocaleString()} + + + + + + + + + + + Pending + + + ${stats.pending.toLocaleString()} + + + + + + + All Activity + + + {loading ? ( + + + + ) : ( + + {invoices.length > 0 ? ( + invoices.map((inv) => ( + nav.go("invoices/[id]", { id: inv.id })} + > + + + + + + + + + {inv.customerName} + + + {new Date(inv.issueDate).toLocaleDateString()} · + Proforma + + + + + ${Number(inv.amount).toLocaleString()} + + + + {inv.status} + + + + + + + + + )) + ) : ( + + + + No activity found + + + )} + + )} + + + ); +} diff --git a/app/invoices/[id].tsx b/app/invoices/[id].tsx index 3a259d1..267d592 100644 --- a/app/invoices/[id].tsx +++ b/app/invoices/[id].tsx @@ -1,6 +1,8 @@ -import React from "react"; -import { View, ScrollView, Pressable } from "react-native"; -import { useLocalSearchParams, router, Stack } from "expo-router"; +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 { Stack, useLocalSearchParams } from "expo-router"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -10,52 +12,66 @@ import { Share2, Download, ArrowLeft, - Tag, - CreditCard, - Building2, ExternalLink, } from "@/lib/icons"; -import { MOCK_INVOICES } from "@/lib/mock-data"; import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper"; - -const MOCK_ITEMS = [ - { - description: "Marketing Landing Page Package", - qty: 1, - unitPrice: 1000, - total: 1000, - }, - { - description: "Instagram Post Initial Design", - qty: 4, - unitPrice: 100, - total: 400, - }, -]; +import { StandardHeader } from "@/components/StandardHeader"; +import { api } from "@/lib/api"; +import { toast } from "@/lib/toast-store"; export default function InvoiceDetailScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); - const invoice = MOCK_INVOICES.find((i) => i.id === id); + const nav = useSirouRouter(); + const { id } = useLocalSearchParams(); + + const [loading, setLoading] = useState(true); + const [invoice, setInvoice] = useState(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 ( + + + + + + + + ); + } + + if (!invoice) { + return ( + + + + + Invoice not found + + + ); + } return ( - - - router.back()} - className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" - > - - - - Invoice Details - - - - - + - {invoice?.status || "Pending"} + {invoice.status || "Pending"} @@ -84,19 +100,19 @@ export default function InvoiceDetailScreen() { Total Amount - ${invoice?.amount.toLocaleString() ?? "—"} + ${Number(invoice.amount).toLocaleString()} - Due {invoice?.dueDate || "—"} + Due {new Date(invoice.dueDate).toLocaleDateString()} - #{invoice?.invoiceNumber || id} + #{invoice.invoiceNumber || id} @@ -107,26 +123,30 @@ export default function InvoiceDetailScreen() { - Recipient + + Recipient + - {invoice?.recipient || "—"} + {invoice.customerName || "—"} - Category + + Category + - Subscription + General @@ -136,33 +156,47 @@ export default function InvoiceDetailScreen() { {/* Items / Billing Summary */} - - + + Billing Summary - {MOCK_ITEMS.map((item, i) => ( - + + + + Subtotal + + + + $ + {( + Number(invoice.amount) - (Number(invoice.taxAmount) || 0) + ).toLocaleString()} + + + + {Number(invoice.taxAmount) > 0 && ( + - {item.description} - - - QTY: {item.qty} · ${item.unitPrice}/unit + Tax - ${item.total.toLocaleString()} + + ${Number(invoice.taxAmount).toLocaleString()} - ))} + )} @@ -172,7 +206,7 @@ export default function InvoiceDetailScreen() { variant="h3" className="text-foreground font-semibold text-xl tracking-tight" > - ${invoice?.amount.toLocaleString() || "0"} + ${Number(invoice.amount).toLocaleString()} @@ -181,22 +215,22 @@ export default function InvoiceDetailScreen() { {/* Actions */} diff --git a/app/login.tsx b/app/login.tsx index 6bacc5f..468ef08 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -1,40 +1,220 @@ -import { View, ScrollView, Pressable } from 'react-native'; -import { router } from 'expo-router'; -import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Mail, ArrowLeft } from '@/lib/icons'; +import React, { useState } from "react"; +import { + View, + ScrollView, + Pressable, + 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() { + const nav = useSirouRouter(); + 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 ( - - Yaltopia Tickets - - - Sign in - Use the same account as the web app. - - - - - - - router.push('/register')} className="mt-4"> - Create account - - - + + + + {/* Logo / Branding */} + + + Login + + + Sign in to manage your tickets & invoices + + + + {/* Form */} + + + + Email or Phone Number + + + + + + + + + + Password + + + + + setShowPassword(!showPassword)}> + {showPassword ? ( + + ) : ( + + )} + + + + + + + + {/* Social / Other */} + + + + + or + + + + + + + {loading ? ( + + ) : ( + <> + + + Continue with Google + + + )} + + + + nav.go("register")} + > + + Don't have an account?{" "} + Create one + + + + + + ); } diff --git a/app/notifications/index.tsx b/app/notifications/index.tsx index 0e95005..7afc460 100644 --- a/app/notifications/index.tsx +++ b/app/notifications/index.tsx @@ -1,31 +1,62 @@ -import { View, ScrollView, Pressable } from 'react-native'; -import { router } from 'expo-router'; -import { Text } from '@/components/ui/text'; -import { Card, CardContent } from '@/components/ui/card'; -import { Bell, Settings, ChevronRight } from '@/lib/icons'; +import { View, ScrollView, Pressable } 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 { Bell, Settings, ChevronRight } from "@/lib/icons"; 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: '3', title: 'Proforma submission', body: 'Vendor A submitted a quote for Marketing Landing Page.', time: '2d ago', read: true }, + { + 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: "3", + title: "Proforma submission", + body: "Vendor A submitted a quote for Marketing Landing Page.", + time: "2d ago", + read: true, + }, ]; export default function NotificationsScreen() { + const nav = useSirouRouter(); return ( - + - Notifications + + Notifications + - router.push('/notifications/settings')}> + nav.go("notifications/settings")} + > Settings {MOCK_NOTIFICATIONS.map((n) => ( - + {n.title} {n.body} diff --git a/app/notifications/settings.tsx b/app/notifications/settings.tsx index e570b8e..912ef59 100644 --- a/app/notifications/settings.tsx +++ b/app/notifications/settings.tsx @@ -1,18 +1,23 @@ -import { View, ScrollView, Switch } from 'react-native'; -import { router } from 'expo-router'; -import { useState } from 'react'; -import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { View, ScrollView, Switch } from "react-native"; +import { useSirouRouter } from "@sirou/react-native"; +import { AppRoutes } from "@/lib/routes"; +import { useState } from "react"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; export default function NotificationSettingsScreen() { + const nav = useSirouRouter(); const [invoiceReminders, setInvoiceReminders] = useState(true); const [daysBeforeDue, setDaysBeforeDue] = useState(2); const [newsAlerts, setNewsAlerts] = useState(true); const [reportReady, setReportReady] = useState(true); return ( - + Notification settings @@ -20,7 +25,10 @@ export default function NotificationSettingsScreen() { Invoice reminders - + News & announcements @@ -33,7 +41,7 @@ export default function NotificationSettingsScreen() { - diff --git a/app/payments/[id].tsx b/app/payments/[id].tsx index 806d278..dc648d5 100644 --- a/app/payments/[id].tsx +++ b/app/payments/[id].tsx @@ -1,5 +1,7 @@ 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 { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -7,7 +9,8 @@ import { ArrowLeft, Wallet, Link2, Clock } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; export default function PaymentDetailScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); + const nav = useSirouRouter(); + const { id } = useSirouParams(); return ( @@ -15,7 +18,7 @@ export default function PaymentDetailScreen() { router.back()} + onPress={() => nav.back()} className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" > diff --git a/app/profile.tsx b/app/profile.tsx index b0dde48..8e47db0 100644 --- a/app/profile.tsx +++ b/app/profile.tsx @@ -9,7 +9,8 @@ import { TouchableOpacity, TouchableWithoutFeedback, } 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 { ArrowLeft, @@ -29,6 +30,11 @@ import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper"; import { useColorScheme } from "nativewind"; import { saveTheme, AppTheme } from "@/lib/theme"; +import { useAuthStore } from "@/lib/auth-store"; + +// ── Constants ───────────────────────────────────────────────────── +const AVATAR_FALLBACK_BASE = + "https://ui-avatars.com/api/?background=ea580c&color=fff&name="; // ── Theme bottom sheet ──────────────────────────────────────────── const THEME_OPTIONS = [ @@ -168,6 +174,8 @@ function MenuItem({ // ── Screen ──────────────────────────────────────────────────────── export default function ProfileScreen() { + const nav = useSirouRouter(); + const { user, logout } = useAuthStore(); const { setColorScheme, colorScheme } = useColorScheme(); const [notifications, setNotifications] = useState(true); const [themeSheetVisible, setThemeSheetVisible] = useState(false); @@ -184,7 +192,7 @@ export default function ProfileScreen() { {/* Header */} router.back()} + onPress={() => nav.back()} className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" > @@ -194,10 +202,10 @@ export default function ProfileScreen() { {/* Edit Profile shortcut */} {}} + onPress={() => nav.go("edit-profile")} className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" > - + @@ -211,19 +219,21 @@ export default function ProfileScreen() { > {/* Avatar */} - + - - Ms. Charlotte + + {user?.firstName} {user?.lastName} - charlotte@example.com + {user?.email} @@ -301,7 +311,7 @@ export default function ProfileScreen() { icon={} label="Log Out" destructive - onPress={() => {}} + onPress={logout} right={null} isLast /> diff --git a/app/proforma/[id].tsx b/app/proforma/[id].tsx index 76da6ae..5f769e4 100644 --- a/app/proforma/[id].tsx +++ b/app/proforma/[id].tsx @@ -1,6 +1,8 @@ -import React from "react"; -import { View, ScrollView, Pressable } from "react-native"; -import { useLocalSearchParams, router, Stack } from "expo-router"; +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 { Stack, useLocalSearchParams } from "expo-router"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -8,104 +10,169 @@ import { ArrowLeft, DraftingCompass, Clock, - Tag, Send, ExternalLink, ChevronRight, CheckCircle2, } from "@/lib/icons"; import { ScreenWrapper } from "@/components/ScreenWrapper"; - -const MOCK_ITEMS = [ - { - 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; +import { StandardHeader } from "@/components/StandardHeader"; +import { api } from "@/lib/api"; +import { toast } from "@/lib/toast-store"; export default function ProformaDetailScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); + const nav = useSirouRouter(); + const { id } = useLocalSearchParams(); + + const [loading, setLoading] = useState(true); + const [proforma, setProforma] = useState(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 ( + + + + + + + + ); + } + + if (!proforma) { + return ( + + + + + Proforma not found + + + ); + } + + const subtotal = + proforma.items?.reduce( + (acc: number, item: any) => acc + (Number(item.total) || 0), + 0, + ) || 0; return ( - - router.back()} - className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" - > - - - - Proforma - - - - - + {/* Header */} + + - + {/* Blue Summary Card */} + - - Open Request + + ACTIVE - Target Package + Customer: {proforma.customerName} - Marketing Landing Page + {proforma.description || "Proforma Request"} + - Expires in 5 days + Due {new Date(proforma.dueDate).toLocaleDateString()} - REQ-{id || "002"} + {proforma.proformaNumber} + {/* Customer Info Strip (Added for functionality while keeping style) */} + + + + + + Email + + + {proforma.customerEmail || "N/A"} + + + + + + + + Phone + + + {proforma.customerPhone || "N/A"} + + + + + + + {/* Line Items Card */} - - + + Line Items - {MOCK_ITEMS.map((item, i) => ( + {proforma.items?.map((item: any, i: number) => ( - {item.qty} × ${item.unitPrice.toLocaleString()} + {item.quantity} × {proforma.currency}{" "} + {Number(item.unitPrice).toLocaleString()} - ${item.total.toLocaleString()} + {proforma.currency} {Number(item.total).toLocaleString()} ))} @@ -133,59 +201,76 @@ export default function ProformaDetailScreen() { Subtotal - ${MOCK_SUBTOTAL.toLocaleString()} - - - - - Tax (10%) - - - ${MOCK_TAX.toLocaleString()} + {proforma.currency} {subtotal.toLocaleString()} + {Number(proforma.taxAmount) > 0 && ( + + + Tax + + + {proforma.currency}{" "} + {Number(proforma.taxAmount).toLocaleString()} + + + )} + {Number(proforma.discountAmount) > 0 && ( + + + Discount + + + -{proforma.currency}{" "} + {Number(proforma.discountAmount).toLocaleString()} + + + )} - - Estimated Total + + Total Amount - ${MOCK_TOTAL.toLocaleString()} + {proforma.currency} {Number(proforma.amount).toLocaleString()} - - Recent Submissions - - - - - - - - + {/* Notes Section (New) */} + {proforma.notes && ( + + + + Additional Notes + - Vendor A — $1,450 - - - Submitted 2 hours ago + {proforma.notes} - - - + + )} + {/* Actions */} - + + {/* Currency Modal */} + setShowCurrency(false)} + title="Select Currency" + > + {CURRENCIES.map((curr) => ( + { + setCurrency(v); + setShowCurrency(false); + }} + /> + ))} + + + {/* Issue Date Modal */} + setShowIssueDate(false)} + title="Select Issue Date" + > + { + setIssueDate(v); + setShowIssueDate(false); + }} + /> + + + {/* Due Date Modal */} + setShowDueDate(false)} + title="Select Due Date" + > + { + setDueDate(v); + setShowDueDate(false); + }} + /> + ); } @@ -363,7 +608,10 @@ function Label({ noMargin?: boolean; }) { return ( - + {children} ); diff --git a/app/register.tsx b/app/register.tsx index 3cc4a35..2dd00d5 100644 --- a/app/register.tsx +++ b/app/register.tsx @@ -1,40 +1,230 @@ -import { View, ScrollView, Pressable } from 'react-native'; -import { router } from 'expo-router'; -import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Mail, ArrowLeft, UserPlus } from '@/lib/icons'; +import React, { useState } from "react"; +import { + View, + ScrollView, + Pressable, + 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() { + const nav = useSirouRouter(); + 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 ( - - Yaltopia Tickets - - - Create account - Register with the same account format as the web app. - - - - - - - router.push('/login')} className="mt-2"> - Already have an account? Sign in - - - + + + + + + Create Account + + + Join Yaltopia and start managing your business + + + + + + + + First Name + + + updateForm("firstName", v)} + /> + + + + + Last Name + + + updateForm("lastName", v)} + /> + + + + + + + Email Address + + + + updateForm("email", v)} + autoCapitalize="none" + keyboardType="email-address" + /> + + + + + + Phone Number + + + + + + +251{" "} + + updateForm("phone", v)} + keyboardType="phone-pad" + /> + + + + + + + Password + + + + updateForm("password", v)} + secureTextEntry + /> + + + + + + + nav.go("login")} + > + + Already have an account?{" "} + Sign In + + + + + ); } diff --git a/app/reports/index.tsx b/app/reports/index.tsx index 908fa7b..01a42f3 100644 --- a/app/reports/index.tsx +++ b/app/reports/index.tsx @@ -1,14 +1,16 @@ -import { View, ScrollView, Pressable } from 'react-native'; -import { router } from 'expo-router'; -import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { FileText, Download, ChevronRight, BarChart3 } from '@/lib/icons'; -import { MOCK_REPORTS } from '@/lib/mock-data'; +import { View, ScrollView, Pressable } from "react-native"; +import { useSirouRouter } from "@sirou/react-native"; +import { AppRoutes } from "@/lib/routes"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +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() { + const nav = useSirouRouter(); return ( {MOCK_REPORTS.map((r) => ( - + @@ -32,8 +37,12 @@ export default function ReportsScreen() { {r.title} - {r.period} - Generated {r.generatedAt} + + {r.period} + + + Generated {r.generatedAt} + @@ -46,7 +55,11 @@ export default function ReportsScreen() { ))} - diff --git a/app/settings.tsx b/app/settings.tsx index 5fa6e39..fa8d75b 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -1,13 +1,15 @@ -import { View, ScrollView, Pressable } from 'react-native'; -import { router } from 'expo-router'; -import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Settings, Bell, Globe, ChevronRight, Info } from '@/lib/icons'; +import { View, ScrollView, Pressable } from "react-native"; +import { useSirouRouter } from "@sirou/react-native"; +import { AppRoutes } from "@/lib/routes"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Settings, Bell, Globe, ChevronRight, Info } from "@/lib/icons"; -const PRIMARY = '#ea580c'; +const PRIMARY = "#ea580c"; export default function SettingsScreen() { + const nav = useSirouRouter(); return ( router.push('/notifications/settings')} + onPress={() => nav.go("notifications/settings")} > @@ -60,11 +62,16 @@ export default function SettingsScreen() { - 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. - diff --git a/app/sms-scan.tsx b/app/sms-scan.tsx new file mode 100644 index 0000000..8a506d6 --- /dev/null +++ b/app/sms-scan.tsx @@ -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(); + const { colorScheme } = useColorScheme(); + const isDark = colorScheme === "dark"; + const [messages, setMessages] = useState([]); + 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 ( + + {/* Header */} + + nav.back()} + className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" + > + + + + Scan SMS + + {/* Spacer */} + + + + Scan SMS + + + Finds banking messages from the last 5 minutes + + + + {/* Scan Button */} + + + {loading ? ( + + ) : ( + <> + + + {scanned ? "Scan Again" : "Scan Now"} + + + )} + + + + + {!scanned && !loading && ( + + + + + + Tap "Scan Now" to search for CBE, Dashen Bank, and Telebirr + messages from the last 5 minutes. + + + )} + + {scanned && messages.length === 0 && ( + + + No banking messages found in the last 5 minutes. + + + )} + + + {messages.map((sms) => { + const bank = getBankLabel(sms); + return ( + + + + + {bank.name} + + + + {formatTime(sms.date)} + + + + {sms.body} + + + From: {sms.address} + + + ); + })} + + + + ); +} diff --git a/app/user/create.tsx b/app/user/create.tsx new file mode 100644 index 0000000..6b98d25 --- /dev/null +++ b/app/user/create.tsx @@ -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(); + 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 ( + + + + + + + + + User Details + + + Configure credentials and system access + + + + + {/* Identity Group */} + + + + First Name + + + updateForm("firstName", v)} + /> + + + + + Last Name + + + updateForm("lastName", v)} + /> + + + + + {/* Email */} + + + Email Address + + + + updateForm("email", v)} + autoCapitalize="none" + keyboardType="email-address" + /> + + + + {/* Phone */} + + + Phone Number + + + + updateForm("phone", v)} + keyboardType="phone-pad" + /> + + + + {/* Role - Dropdown */} + + + System Role + + setShowRolePicker(true)} + className="flex-row items-center bg-secondary/30 rounded-xl px-4 border border-border h-12" + > + + + {form.role} + + + + + + {/* Password */} + + + Initial Password + + + + updateForm("password", v)} + secureTextEntry + /> + + + + + + + + + setShowRolePicker(false)} + title="Select System Role" + > + {ROLES.map((role) => ( + { + updateForm("role", v); + setShowRolePicker(false); + }} + /> + ))} + + + ); +} diff --git a/assets/google-logo.png b/assets/google-logo.png new file mode 100644 index 0000000..3efcd81 Binary files /dev/null and b/assets/google-logo.png differ diff --git a/components/CalendarGrid.tsx b/components/CalendarGrid.tsx new file mode 100644 index 0000000..e64abad --- /dev/null +++ b/components/CalendarGrid.tsx @@ -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 ( + + + changeMonth(-1)} + className="h-12 w-12 bg-white rounded-[12px] items-center justify-center border border-border" + style={isDark ? { backgroundColor: "#1e1e1e" } : undefined} + > + + + + + + {MONTHS[month]} {year} + + + + changeMonth(1)} + className="h-12 w-12 bg-white rounded-[12px] items-center justify-center border border-border" + style={isDark ? { backgroundColor: "#1e1e1e" } : undefined} + > + + + + + {/* WeekDays Header: Mo Tu We Th Fr Sa Su */} + + {WEEKDAYS.map((day, idx) => ( + + + {day} + + + ))} + + + {/* Grid */} + + {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 ( + + onSelect(iso)} + className={`w-11 h-11 items-center justify-center rounded-full ${ + isSelected ? "bg-primary" : "" + }`} + > + + {d.getDate()} + + {isToday && !isSelected && ( + + )} + + + ); + })} + + + ); +} diff --git a/components/PickerModal.tsx b/components/PickerModal.tsx new file mode 100644 index 0000000..7fa1c4f --- /dev/null +++ b/components/PickerModal.tsx @@ -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 ( + + + + e.stopPropagation()} + > + {/* Drag Handle */} + + + + + {/* Header */} + + + + {title} + + + + + + + + {children} + + + + + + ); +} + +export function SelectOption({ + label, + value, + selected, + onSelect, +}: { + label: string; + value: string; + selected: boolean; + onSelect: (v: string) => void; +}) { + return ( + 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" + }`} + > + + {label} + + + {selected && } + + + ); +} diff --git a/components/StandardHeader.tsx b/components/StandardHeader.tsx index 652dd84..5934959 100644 --- a/components/StandardHeader.tsx +++ b/components/StandardHeader.tsx @@ -1,46 +1,83 @@ -import React from "react"; -import { View, Image, Pressable } from "react-native"; +import { View, Image, Pressable, useColorScheme } from "react-native"; import { Text } from "@/components/ui/text"; -import { Bell } from "@/lib/icons"; +import { ArrowLeft, Bell } from "@/lib/icons"; import { ShadowWrapper } from "@/components/ShadowWrapper"; -import { MOCK_USER } from "@/lib/mock-data"; +import { useAuthStore } from "@/lib/auth-store"; 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 ( - - + + {showBack && ( router.push("/profile")} - className="h-[40px] w-[40px] rounded-full border-2 border-primary/20 overflow-hidden" + onPress={() => router.back()} + className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" > - + + )} + + {!title ? ( + + + router.push("/profile")} + className="h-[40px] w-[40px] rounded-full overflow-hidden" + > + + + + + + Welcome back, + + + {user?.firstName + " " + user?.lastName || "User"} + + + + ) : ( + + + {title} + + + )} + + + {!title && ( + + + - - - Welcome back, - - - {MOCK_USER.name} - - - + )} - - - - - + {title && } ); } diff --git a/components/Toast.tsx b/components/Toast.tsx new file mode 100644 index 0000000..de9fde6 --- /dev/null +++ b/components/Toast.tsx @@ -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: , + }, + info: { + bg: "#f0f9ff", + border: "#0ea5e9", + icon: , + }, + warning: { + bg: "#fffbeb", + border: "#f59e0b", + icon: , + }, + error: { + bg: "#fef2f2", + border: "#ef4444", + icon: , + }, +}; + +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 ( + + + {variant.icon} + + + + {title} + + + {message} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: "absolute", + left: 16, + right: 16, + zIndex: 9999, + }, +}); diff --git a/lib/api-middlewares.ts b/lib/api-middlewares.ts new file mode 100644 index 0000000..33ca9d7 --- /dev/null +++ b/lib/api-middlewares.ts @@ -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; + } +}; diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..5d1951c --- /dev/null +++ b/lib/api.ts @@ -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; diff --git a/lib/auth-guards.ts b/lib/auth-guards.ts new file mode 100644 index 0000000..9f1bbed --- /dev/null +++ b/lib/auth-guards.ts @@ -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 => { + 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 => { + 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 }; + }, +}; diff --git a/lib/auth-store.ts b/lib/auth-store.ts new file mode 100644 index 0000000..d2b15a5 --- /dev/null +++ b/lib/auth-store.ts @@ -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; + updateUser: (user: Partial) => void; +} + +export const useAuthStore = create()( + 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), + }, + ), +); diff --git a/lib/icons.tsx b/lib/icons.tsx index b2e904f..dfe00a0 100644 --- a/lib/icons.tsx +++ b/lib/icons.tsx @@ -54,4 +54,22 @@ export { HelpCircle, ArrowUpRight, Lock, + ArrowRight, + Eye, + EyeOff, + Github, + Phone, + Chrome, + Triangle, + Triangle as TrianglePlanets, + AlertTriangle, + Lightbulb, + Check, + MessageSquare, + RefreshCw, + Banknote, + Newspaper, + ChevronDown, + CalendarSearch, + Search, } from "lucide-react-native"; diff --git a/lib/routes.ts b/lib/routes.ts new file mode 100644 index 0000000..c7c1b99 --- /dev/null +++ b/lib/routes.ts @@ -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; diff --git a/lib/toast-store.ts b/lib/toast-store.ts new file mode 100644 index 0000000..e71a1e3 --- /dev/null +++ b/lib/toast-store.ts @@ -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((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 }), +}; diff --git a/package-lock.json b/package-lock.json index 93ee343..3ca1c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,18 @@ "": { "name": "yaltopia-tickets-app", "version": "1.0.0", + "hasInstallScript": true, "dependencies": { "@expo/metro-runtime": "~4.0.1", "@react-native-async-storage/async-storage": "1.23.1", + "@react-native-community/datetimepicker": "8.2.0", "@react-navigation/native": "^7.0.14", "@rn-primitives/portal": "^1.1.0", "@rn-primitives/slot": "^1.1.0", + "@simple-api/core": "^1.0.4", + "@simple-api/react-native": "^1.0.4", + "@sirou/core": "^1.1.0", + "@sirou/react-native": "^1.1.0", "babel-preset-expo": "~11.0.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -24,19 +30,22 @@ "expo-router": "~4.0.17", "expo-status-bar": "~2.0.1", "expo-system-ui": "~4.0.9", + "expo-web-browser": "~14.0.2", "lucide-react-native": "^0.471.0", "nativewind": "^4.1.23", "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.7", "react-native-gesture-handler": "~2.20.2", + "react-native-get-sms-android": "^2.1.0", "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "15.8.0", "react-native-web": "~0.19.13", "tailwind-merge": "^3.0.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.11" }, "devDependencies": { "@types/react": "~18.3.12", @@ -64,6 +73,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -555,90 +565,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/plugin-proposal-async-generator-functions": { "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.7.tgz", @@ -813,19 +739,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -934,22 +847,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", @@ -1121,23 +1018,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/plugin-transform-arrow-functions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", @@ -1187,22 +1067,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-block-scoping": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", @@ -1234,23 +1098,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, "node_modules/@babel/plugin-transform-classes": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", @@ -1303,105 +1150,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-export-namespace-from": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", @@ -1466,22 +1214,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-literals": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", @@ -1512,39 +1244,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-modules-commonjs": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", @@ -1561,42 +1260,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", @@ -1613,22 +1276,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", @@ -1678,23 +1325,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-optional-catch-binding": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", @@ -1774,22 +1404,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-react-display-name": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", @@ -1900,39 +1514,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-runtime": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", @@ -2023,22 +1604,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-typescript": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", @@ -2058,39 +1623,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-unicode-regex": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", @@ -2107,132 +1639,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz", - "integrity": "sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.0", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.15", - "babel-plugin-polyfill-corejs3": "^0.14.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "core-js-compat": "^3.48.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.0.tgz", - "integrity": "sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.6", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/preset-flow": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.27.1.tgz", @@ -2250,21 +1656,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/@babel/preset-react": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", @@ -3488,6 +2879,29 @@ "react-native": "^0.0.0-0 || >=0.60 <1.0" } }, + "node_modules/@react-native-community/datetimepicker": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-8.2.0.tgz", + "integrity": "sha512-qrUPhiBvKGuG9Y+vOqsc56RPFcHa1SU2qbAMT0hfGkoFIj3FodE0VuPVrEa8fgy7kcD5NQmkZIKgHOBLV0+hWg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": ">=50.0.0", + "react": "*", + "react-native": "*", + "react-native-windows": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-native-windows": { + "optional": true + } + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.7", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.7.tgz", @@ -4098,6 +3512,35 @@ "join-component": "^1.1.0" } }, + "node_modules/@simple-api/core": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@simple-api/core/-/core-1.0.4.tgz", + "integrity": "sha512-DUk84GsZSCQpaDUefCMbdNHw/0LgNoP+oyUjgewKIiS/CcH/bwTOpK37zJFsZ+zT7CPalcompbgaEtVkuBjGXg==" + }, + "node_modules/@simple-api/react": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@simple-api/react/-/react-1.0.1.tgz", + "integrity": "sha512-2Jv7fHDfTSABWkP5prIOo2emlKfx1B5+vRQDXzkwjnxiyP+GzlgXLgyc6Oa8+D5h1yUF9zQm9htkkYj/26Ocqw==", + "dependencies": { + "@simple-api/core": "*" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.0.0", + "react": "^18.0.0" + } + }, + "node_modules/@simple-api/react-native": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@simple-api/react-native/-/react-native-1.0.4.tgz", + "integrity": "sha512-x4r59TDgDdzyTRq4XV9wD8wtYMj/Dvxrtu3k56iqMNSK0YRSFELNfxKI7xQVX3zYqR+1tqVBh/nvnRUyp1eCKA==", + "dependencies": { + "@simple-api/react": "*" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-native": "*" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -4122,6 +3565,32 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@sirou/core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@sirou/core/-/core-1.1.0.tgz", + "integrity": "sha512-TBcEdgZ8LoGifpmh0oDkn54ABFgjcUDgp7Igg5SiDdm4PrOnwXqj44Nz9nXcm7OXyhh5+T4/thOjPfTRGZjUoA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@sirou/react-native": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@sirou/react-native/-/react-native-1.1.0.tgz", + "integrity": "sha512-JUV7Grt6FA8bL9Sb/he8G168Yt79K9+TcRO4G2JhC1D1S2ZoXBaIQ3XtDGcRWyKvC+iz9EhE8X07OpKg1y9w4A==", + "license": "MIT", + "dependencies": { + "@sirou/core": "^1.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "^18.0.0", + "react-native": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4230,14 +3699,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -5086,6 +4555,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5373,6 +4843,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5427,6 +4898,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -5451,6 +4923,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -5969,6 +5442,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -5981,7 +5455,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -6146,6 +5620,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, "license": "Apache-2.0" }, "node_modules/dir-glob": { @@ -6164,6 +5639,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, "license": "MIT" }, "node_modules/dom-serializer": { @@ -6434,16 +5910,6 @@ "node": ">=4" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -6913,6 +6379,16 @@ "integrity": "sha512-FRjRvs7RgsXjkbGSOjYSxhX5V70c0IzA/jy3HXeYpATMwD9fOR1DbveLW497QGsVdCa0vThbJUtR8rIzAfpHQA==", "license": "MIT" }, + "node_modules/expo-web-browser": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.0.2.tgz", + "integrity": "sha512-Hncv2yojhTpHbP6SGWARBFdl7P6wBHc1O8IKaNsH0a/IEakq887o1eRhLxZ5IwztPQyRDhpqHdgJ+BjWolOnwA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@react-native/babel-plugin-codegen": { "version": "0.76.9", "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.76.9.tgz", @@ -7562,6 +7038,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -7964,6 +7441,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -8369,6 +7847,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -8824,6 +8303,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -9901,6 +9381,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -10362,6 +9843,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10522,6 +10004,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -10539,6 +10022,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -10564,6 +10048,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, "funding": [ { "type": "opencollective", @@ -10606,6 +10091,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -10631,6 +10117,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -10646,23 +10133,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/prettier-plugin-tailwindcss": { "version": "0.5.14", "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.14.tgz", @@ -11136,6 +10606,15 @@ "react-native": "*" } }, + "node_modules/react-native-get-sms-android": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-native-get-sms-android/-/react-native-get-sms-android-2.1.0.tgz", + "integrity": "sha512-yYPlJ4DkuC9HnUL0ni644pDjRFnSQkdGHowIY5ab56YFDKHIEZ1rKuBCEbCWF0HALyvH6qCyfdHqwpzTtIj97w==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/react-native-helmet-async": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-native-helmet-async/-/react-native-helmet-async-2.0.4.tgz", @@ -11334,6 +10813,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -11343,6 +10823,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -12227,6 +11708,7 @@ "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -12249,6 +11731,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -12305,6 +11788,7 @@ "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -12608,6 +12092,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -12624,6 +12109,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -12641,6 +12127,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -12932,6 +12419,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -13286,7 +12774,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 6a1a41e..3810261 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,14 @@ "dependencies": { "@expo/metro-runtime": "~4.0.1", "@react-native-async-storage/async-storage": "1.23.1", + "@react-native-community/datetimepicker": "8.2.0", "@react-navigation/native": "^7.0.14", "@rn-primitives/portal": "^1.1.0", "@rn-primitives/slot": "^1.1.0", + "@simple-api/core": "^1.0.4", + "@simple-api/react-native": "^1.0.4", + "@sirou/core": "^1.1.0", + "@sirou/react-native": "^1.1.0", "babel-preset-expo": "~11.0.15", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -26,19 +31,22 @@ "expo-router": "~4.0.17", "expo-status-bar": "~2.0.1", "expo-system-ui": "~4.0.9", + "expo-web-browser": "~14.0.2", "lucide-react-native": "^0.471.0", "nativewind": "^4.1.23", "react": "18.3.1", "react-dom": "18.3.1", "react-native": "0.76.7", "react-native-gesture-handler": "~2.20.2", + "react-native-get-sms-android": "^2.1.0", "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "15.8.0", "react-native-web": "~0.19.13", "tailwind-merge": "^3.0.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.11" }, "devDependencies": { "@types/react": "~18.3.12",