This commit is contained in:
elnatansamuel25 2026-05-14 22:29:28 +03:00
parent db5ac60987
commit 1b5e82c895
17 changed files with 1177 additions and 406 deletions

16
AGENTS.md Normal file
View File

@ -0,0 +1,16 @@
<!-- BEGIN:react-native-agent-rules -->
# This is NOT the react native you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/react-native/dist/docs/` before writing any code. Heed deprecation notices.
## UI Language Rules
- **Use Plain Language**: Always use simple, direct words in the UI (e.g., "Staff" instead of "Personnel", "Orders" instead of "Orchestration") and no italic.
- **No Jargon**: Avoid corporate or "agentic" bullshit. No "synchronization", "temporal dispatch", or "fiscal identity". Use "Sync", "Time", or "Financial Info".
- **Be Direct**: Text should be clear and functional. If a word sounds like it came from a corporate meeting, don't use it.
- **use correct icons**: use the icons that are relevant
- **dont use gradients**: do not use gradients as bg or anything bro
- **make the ui the same as the other screens**: look at `/app` folder to understand the ui structure and how to use it
<!-- END:react-native-agent-rules -->

View File

@ -14,7 +14,16 @@
}, },
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.yaltopia.ticketapp" "bundleIdentifier": "com.yaltopia.ticketapp",
"infoPlist": {
"CFBundleURLTypes": [
{
"CFBundleURLSchemes": [
"com.googleusercontent.apps.1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi"
]
}
]
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {

View File

@ -5,10 +5,19 @@ import {
Platform, Platform,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Image,
StyleSheet,
} from "react-native"; } from "react-native";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Zap, Camera as CameraIcon, ScanLine } from "@/lib/icons"; import {
X,
Zap,
Camera as CameraIcon,
ScanLine,
Check,
RefreshCw,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { CameraView, useCameraPermissions } from "expo-camera"; import { CameraView, useCameraPermissions } from "expo-camera";
import { useSirouRouter } from "@sirou/react-native"; import { useSirouRouter } from "@sirou/react-native";
@ -24,7 +33,9 @@ export default function ScanScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [torch, setTorch] = useState(false); const [torch, setTorch] = useState(false);
const [scanType, setScanType] = useState<"invoice" | "receipt">("invoice");
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [previewUri, setPreviewUri] = useState<string | null>(null);
const cameraRef = useRef<CameraView>(null); const cameraRef = useRef<CameraView>(null);
const navigation = useNavigation(); const navigation = useNavigation();
const token = useAuthStore((s) => s.token); const token = useAuthStore((s) => s.token);
@ -56,69 +67,86 @@ export default function ScanScreen() {
}; };
}, [navigation]); }, [navigation]);
const handleScan = async () => { const handleCapture = async () => {
if (!cameraRef.current || scanning) return; if (!cameraRef.current || scanning) return;
try {
const photo = await cameraRef.current.takePictureAsync({
quality: 0.8,
base64: false,
});
if (photo?.uri) {
setPreviewUri(photo.uri);
}
} catch (err) {
console.error("[Scan] Capture Error:", err);
toast.error("Capture Failed", "Could not take a photo.");
}
};
const handleProcess = async () => {
if (!previewUri || scanning) return;
setScanning(true); setScanning(true);
try { try {
// 1. Capture the photo const label = scanType === "invoice" ? "invoice" : "receipt";
const photo = await cameraRef.current.takePictureAsync({ toast.info("Processing...", `Uploading ${label} image for AI extraction.`);
quality: 0.85,
base64: false,
});
if (!photo?.uri) throw new Error("Failed to capture photo."); // Build multipart form data with the image file (binary)
toast.info("Scanning...", "Uploading invoice image for AI extraction.");
// 2. Build multipart form data with the image file
const formData = new FormData(); const formData = new FormData();
const fileExt = previewUri.split(".").pop() || "jpg";
const fileName = `${label}-${Date.now()}.${fileExt}`;
const type = `image/${fileExt === "jpg" ? "jpeg" : fileExt}`;
// In React Native, the file object in FormData needs special treatment
formData.append("file", { formData.append("file", {
uri: photo.uri, uri: Platform.OS === "android" ? previewUri : previewUri.replace("file://", ""),
name: "invoice.jpg", name: fileName,
type: "image/jpeg", type: type,
} as any); } as any);
// 3. POST to /api/v1/scan/invoice const endpoint =
const response = await fetch(`${BASE_URL}scan/invoice`, { scanType === "invoice" ? "scan/invoice" : "scan/payment-receipt";
const response = await fetch(`${BASE_URL}${endpoint}`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
// Do NOT set Content-Type here — fetch sets it automatically with the boundary for multipart Accept: "application/json",
// Boundary is set automatically by fetch when body is FormData
}, },
body: formData, body: formData,
}); });
if (!response.ok) { if (!response.ok) {
const err = await response.json(); const err = await response.json().catch(() => ({ message: "Scan processing failed." }));
throw new Error(err.message || "Scan failed."); throw new Error(err.message || "Extraction failed.");
} }
const scanResult = await response.json(); const scanResult = await response.json();
console.log("[Scan] Extracted invoice data:", scanResult); console.log(`[Scan] Extracted ${label} data:`, scanResult);
if (!scanResult.success) { if (!scanResult.success) {
throw new Error(scanResult.message || "Extraction failed."); throw new Error(scanResult.message || "AI extraction was unsuccessful.");
} }
toast.success("Scan Complete!", "Drafting your invoice now..."); toast.success("Success!", `Extracted data from ${label} successfully.`);
// 4. Map OCR data to Invoice structure // 4. Map OCR data to structure
const ocr = scanResult.data || {}; const ocr = scanResult.data || {};
const invoicePayload = { const invoicePayload = {
invoiceNumber: ocr.invoiceNumber || `INV-${Date.now()}`, invoiceNumber: ocr.invoiceNumber || `DOC-${Date.now()}`,
customerName: ocr.customerName?.trim() || "Unknown Customer", customerName: ocr.customerName?.trim() || "Unknown Entity",
customerEmail: ocr.customerEmail || "", customerEmail: ocr.customerEmail || "",
customerPhone: ocr.customerPhone || "", customerPhone: ocr.customerPhone || "",
amount: ocr.totalAmount || ocr.subtotalAmount || 0, amount: ocr.totalAmount || ocr.subtotalAmount || 0,
currency: ocr.currency || "ETB", currency: ocr.currency || "ETB",
type: "SALES", type: scanType === "invoice" ? "SALES" : "EXPENSE",
status: "DRAFT", status: "DRAFT",
issueDate: ocr.issueDate issueDate: ocr.issueDate
? new Date(ocr.issueDate).toISOString() ? new Date(ocr.issueDate).toISOString()
: new Date().toISOString(), : new Date().toISOString(),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
description: `Scanned Invoice #${ocr.invoiceNumber || ""}`, description: `Scanned ${scanType === "invoice" ? "Invoice" : "Receipt"} #${ocr.invoiceNumber || ""}`,
notes: scanResult.message || "Automatically generated from scan.", notes: scanResult.message || "Automatically generated from scan.",
taxAmount: ocr.taxAmount || 0, taxAmount: ocr.taxAmount || 0,
discountAmount: 0, discountAmount: 0,
@ -136,27 +164,25 @@ export default function ScanScreen() {
})), })),
}; };
// 5. Create the invoice in the backend // 5. Create the record in the backend
const createResponse = await api.invoices.create({ const createResponse = await api.invoices.create({
body: invoicePayload, body: invoicePayload,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
}); });
console.log("[Scan] Invoice created successfully:", createResponse); console.log("[Scan] Record created successfully:", createResponse);
toast.success("Success!", "Invoice created and ready for review.");
// 6. Navigate to the new invoice detail page
if (createResponse?.id) { if (createResponse?.id) {
nav.go(`invoices/${createResponse.id}`); nav.go(`invoices/${createResponse.id}`);
} else { } else {
nav.go("(tabs)/payments"); nav.go("(tabs)/payments");
} }
} catch (err: any) { } catch (err: any) {
console.error("[Scan] Error:", err); console.error("[Scan] Processing Error:", err);
toast.error( toast.error("Processing Failed", err.message || "Document extraction failed.");
"Scan Failed",
err.message || "Could not process the invoice.",
);
} finally { } finally {
setScanning(false); setScanning(false);
} }
@ -196,6 +222,59 @@ export default function ScanScreen() {
return ( return (
<View className="flex-1 bg-black"> <View className="flex-1 bg-black">
{previewUri ? (
<View className="flex-1">
<Image
source={{ uri: previewUri }}
style={StyleSheet.absoluteFillObject}
resizeMode="cover"
/>
<View className="flex-1 justify-between p-10 pt-16 bg-black/20">
<View className="flex-row justify-between items-center">
<Text className="text-white font-black uppercase tracking-widest text-lg shadow-xl">
Preview
</Text>
<Pressable
onPress={() => setPreviewUri(null)}
className="h-12 w-12 rounded-full bg-black/40 items-center justify-center border border-white/20"
>
<X color="white" size={24} />
</Pressable>
</View>
<View className="flex-row gap-4 items-center justify-center pb-10">
<Button
variant="ghost"
className="flex-1 h-16 rounded-2xl bg-black/40 border border-white/20"
onPress={() => setPreviewUri(null)}
disabled={scanning}
>
<RefreshCw color="white" size={20} className="mr-2" />
<Text className="text-white font-bold uppercase tracking-widest text-xs">
Retake
</Text>
</Button>
<Button
className="flex-1 h-16 rounded-2xl bg-primary shadow-2xl"
onPress={handleProcess}
disabled={scanning}
>
{scanning ? (
<ActivityIndicator color="white" />
) : (
<>
<Check color="white" size={24} className="mr-2" />
<Text className="text-white font-bold uppercase tracking-widest text-xs">
Extract
</Text>
</>
)}
</Button>
</View>
</View>
</View>
) : (
<CameraView <CameraView
ref={cameraRef} ref={cameraRef}
style={{ flex: 1 }} style={{ flex: 1 }}
@ -225,34 +304,54 @@ export default function ScanScreen() {
</View> </View>
{/* Scan Frame */} {/* Scan Frame */}
<View className="items-center mt-10"> <View className="items-center">
<View className="w-[300px] h-[500px] border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center"> <View className="w-[300px] h-[500px] border-2 border-primary rounded-3xl border-dashed opacity-80 items-center justify-center">
<View className="w-[280px] h-[480px] border border-white/10 rounded-2xl" /> <View className="w-[280px] h-[380px] border border-white/10 rounded-2xl" />
</View> </View>
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
Align Invoice Within Frame
</Text>
</View> </View>
{/* Capture Button */} {/* Capture Button & Tabs */}
<View className="items-center pb-10 gap-4"> <View className="items-center gap-6">
{/* Tabs */}
<View className="flex-row bg-white/10 self-center rounded-2xl border border-white/10 p-1">
<Pressable <Pressable
onPress={handleScan} onPress={() => setScanType("invoice")}
disabled={scanning} className={`px-6 py-2 rounded-xl ${scanType === "invoice" ? "bg-primary" : "bg-transparent"}`}
className="h-20 w-20 rounded-full bg-primary items-center justify-center border-4 border-white/30" >
<Text
className={`font-bold text-xs uppercase tracking-widest ${scanType === "invoice" ? "text-white" : "text-white/50"}`}
>
Invoice
</Text>
</Pressable>
<Pressable
onPress={() => setScanType("receipt")}
className={`px-6 py-2 rounded-xl ${scanType === "receipt" ? "bg-primary" : "bg-transparent"}`}
>
<Text
className={`font-bold text-xs uppercase tracking-widest ${scanType === "receipt" ? "text-white" : "text-white/50"}`}
>
Receipt
</Text>
</Pressable>
</View>
<View className="items-center gap-4">
<Pressable
onPress={handleCapture}
disabled={scanning}
className="h-16 w-16 rounded-full bg-primary items-center justify-center border-4 border-white/30 shadow-2xl"
> >
{scanning ? (
<ActivityIndicator color="white" size="large" />
) : (
<ScanLine color="white" size={32} /> <ScanLine color="white" size={32} />
)}
</Pressable> </Pressable>
<Text className="text-white/50 text-[10px] font-black uppercase tracking-widest"> <Text className="text-white/50 text-[10px] font-black uppercase tracking-widest">
{scanning ? "Extracting Data..." : "Tap to Scan"} {"Tap to Capture"}
</Text> </Text>
</View> </View>
</View> </View>
</View>
</CameraView> </CameraView>
)}
</View> </View>
); );
} }

View File

@ -27,11 +27,17 @@ import { useFonts } from "expo-font";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useColorScheme } from "nativewind"; import { useColorScheme } from "nativewind";
import { useSegments, useLocalSearchParams, useRouter } from "expo-router"; import { useSegments, useLocalSearchParams, router } from "expo-router";
function BackupGuard() { /**
* GlobalGuard: Handles all routing security and authentication redirects.
* Reacts instantly to auth state changes to prevent unauthenticated users from seeing protected data.
*/
function GlobalGuard() {
const segments = useSegments(); const segments = useSegments();
const isAuthed = useAuthStore((s) => s.isAuthenticated); const params = useLocalSearchParams();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const sirou = useSirouRouter();
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
useEffect(() => { useEffect(() => {
@ -40,7 +46,42 @@ function BackupGuard() {
useEffect(() => { useEffect(() => {
if (!isMounted) return; if (!isMounted) return;
}, [segments, isAuthed, isMounted]);
const performGuardCheck = async () => {
const routeName = segments.length > 0 ? segments.join("/") : "root";
const isAuthPage =
segments[0] === "login" ||
segments[0] === "register" ||
segments[0] === "otp";
// 1. FAST AUTH CHECK: If not authenticated and not on a public page, eject immediately.
if (!isAuthenticated && !isAuthPage) {
console.log(`[GlobalGuard] Unauthorized on "${routeName}". Ejecting...`);
router.replace("/login");
return;
}
// 2. GUEST CHECK: If authenticated and on an auth page, redirect to home.
if (isAuthenticated && isAuthPage) {
console.log(`[GlobalGuard] Authenticated user on auth page. Sending home.`);
router.replace("/");
return;
}
// 3. COMPLEX GUARDS: Permissions, roles, etc. handled by Sirou.
try {
const result = await (sirou as any).checkGuards(routeName, params);
if (!result.allowed && result.redirect) {
console.log(`[GlobalGuard] Sirou Guard Redirect -> ${result.redirect}`);
router.replace(result.redirect as any);
}
} catch (e: any) {
console.warn(`[GlobalGuard] guard crash:`, e.message);
}
};
performGuardCheck();
}, [segments, params, sirou, isMounted, isAuthenticated]);
return null; return null;
} }
@ -86,52 +127,6 @@ function SessionHeartbeat() {
return null; return null;
} }
function SirouBridge() {
const sirou = useSirouRouter();
const router = useRouter();
const segments = useSegments();
const params = useLocalSearchParams();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isMounted) return;
const checkAuth = async () => {
const routeName = segments.length > 0 ? segments.join("/") : "root";
console.log(
`[SirouBridge] checking route: "${routeName}" with params:`,
params,
);
try {
const result = await (sirou as any).checkGuards(routeName, params);
if (!result.allowed && result.redirect) {
console.log(`[SirouBridge] REDIRECT -> ${result.redirect}`);
InteractionManager.runAfterInteractions(() => {
// Use Expo Router directly — sirou.go fires NAVIGATE which Expo can't resolve
router.replace(result.redirect as any);
});
}
} catch (e: any) {
console.warn(
`[SirouBridge] guard crash for "${routeName}":`,
e.message,
);
}
};
checkAuth();
}, [segments, params, sirou, router, isMounted, isAuthenticated]);
return null;
}
export default function RootLayout() { export default function RootLayout() {
const { colorScheme } = useColorScheme(); const { colorScheme } = useColorScheme();
useRestoreTheme(); useRestoreTheme();
@ -190,6 +185,7 @@ export default function RootLayout() {
> >
<View className="flex-1 bg-background"> <View className="flex-1 bg-background">
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} /> <StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<GlobalGuard />
<Stack <Stack
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
@ -229,6 +225,7 @@ export default function RootLayout() {
options={{ title: "Notification settings" }} options={{ title: "Notification settings" }}
/> />
<Stack.Screen name="help" options={{ headerShown: false }} /> <Stack.Screen name="help" options={{ headerShown: false }} />
<Stack.Screen name="faq" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} /> <Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} /> <Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="history" options={{ headerShown: false }} /> <Stack.Screen name="history" options={{ headerShown: false }} />
@ -260,8 +257,6 @@ export default function RootLayout() {
options={{ headerShown: false }} options={{ headerShown: false }}
/> />
</Stack> </Stack>
<SirouBridge />
<BackupGuard />
<SessionHeartbeat /> <SessionHeartbeat />
<PortalHost /> <PortalHost />
<Toast /> <Toast />

View File

@ -85,6 +85,7 @@ export default function CompanyScreen() {
className="flex-1 ml-3 text-foreground" className="flex-1 ml-3 text-foreground"
placeholder="Search workers..." placeholder="Search workers..."
placeholderTextColor={getPlaceholderColor(isDark)} placeholderTextColor={getPlaceholderColor(isDark)}
placeholderClassName="pb-4"
value={searchQuery} value={searchQuery}
onChangeText={setSearchQuery} onChangeText={setSearchQuery}
/> />

127
app/faq.tsx Normal file
View File

@ -0,0 +1,127 @@
import React, { useEffect, useState } from "react";
import { View, ScrollView, ActivityIndicator, Pressable } from "react-native";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader";
import { Text } from "@/components/ui/text";
import { Card, CardContent } from "@/components/ui/card";
import { faqApi } from "@/lib/api";
import { ChevronDown } from "@/lib/icons";
import { useColorScheme } from "nativewind";
import { cn } from "@/lib/utils";
interface FAQItem {
id: string;
question: string;
answer: string;
category?: string;
}
export default function FAQScreen() {
const [faqs, setFaqs] = useState<FAQItem[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<string | null>(null);
const { colorScheme } = useColorScheme();
const fetchFaqs = async () => {
try {
const response = await faqApi.getAll();
const faqData = (response as any)?.data || response;
setFaqs(Array.isArray(faqData) ? faqData : []);
} catch (error) {
console.error("[FAQ] Fetch failed:", error);
setFaqs([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchFaqs();
}, []);
return (
<ScreenWrapper className="bg-background">
<StandardHeader title="FAQ" showBack />
{loading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#ea580c" />
</View>
) : (
<ScrollView
className="flex-1 px-4 pt-4"
contentContainerStyle={{ paddingBottom: 40 }}
showsVerticalScrollIndicator={false}
>
<View className="mb-6 px-1">
<Text variant="h4" className="text-foreground font-bold">
Got Questions?
</Text>
<Text variant="muted" className="text-sm mt-1">
Find quick answers to common inquiries.
</Text>
</View>
{faqs.length === 0 ? (
<View className="items-center justify-center mt-20 p-10">
<Text variant="muted" className="text-center">
No FAQs available yet.
</Text>
</View>
) : (
faqs.map((item) => (
<Card
key={item.id}
className="mb-4 overflow-hidden border border-border/50 bg-card rounded-[6px] shadow-sm"
>
<Pressable
onPress={() =>
setExpanded(expanded === item.id ? null : item.id)
}
className={cn(
"flex-row items-center justify-between p-3",
expanded === item.id && "bg-muted/10",
)}
>
<Text className="flex-1 font-bold text-foreground mr-3 leading-5">
{item.question}
</Text>
<View
className="h-8 w-8 rounded-[10px] bg-card items-center justify-center border border-border/40"
style={{
transform: [
{ rotate: expanded === item.id ? "180deg" : "0deg" },
],
}}
>
<ChevronDown
size={18}
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
/>
</View>
</Pressable>
{expanded === item.id && (
<CardContent className="px-4 pb-5">
<View className="h-[1px] bg-border/30 mb-4" />
<Text className="text-foreground leading-6 text-[15px]">
{item.answer}
</Text>
{item.category && (
<View className="flex-row mt-4">
<View className="bg-primary/5 px-2.5 py-1 rounded-[6px] border border-primary/10">
<Text className="text-[10px] font-bold text-primary uppercase">
{item.category}
</Text>
</View>
</View>
)}
</CardContent>
)}
</Card>
))
)}
</ScrollView>
)}
</ScreenWrapper>
);
}

View File

@ -1,59 +1,589 @@
import { View } from "react-native"; import React, { useEffect, useState } from "react";
import {
View,
ScrollView,
ActivityIndicator,
Pressable,
Modal,
TextInput,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { StandardHeader } from "@/components/StandardHeader"; import { StandardHeader } from "@/components/StandardHeader";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { supportApi } from "@/lib/api";
import { useAuthStore } from "@/lib/auth-store";
import { toast } from "@/lib/toast-store";
import {
Plus,
MessageSquare,
AlertCircle,
Clock,
CheckCircle2,
ChevronRight,
X,
Calendar,
User as UserIcon,
Tag,
} from "@/lib/icons";
import { useColorScheme } from "nativewind";
import { cn } from "@/lib/utils";
const FAQ = [ interface SupportTicket {
id: string;
ticketNumber: string;
subject: string;
message: string;
status: "OPEN" | "PENDING" | "IN_PROGRESS" | "RESOLVED" | "CLOSED";
priority: "URGENT" | "HIGH" | "MEDIUM" | "LOW";
createdAt: string;
updatedAt: string;
resolution?: string;
requesterName?: string;
requesterEmail: string;
}
const PRIORITIES = [
{ {
q: "How do I change the theme?", label: "Urgent",
a: "Go to Profile > Appearance and choose Light, Dark, or System.", value: "URGENT",
color: "text-red-500",
bg: "bg-red-500/10",
dot: "bg-red-500",
}, },
{ {
q: "Where can I find my invoices and proformas?", label: "High",
a: "Use the tabs on the bottom navigation to browse Invoices/Payments and Proformas.", value: "HIGH",
color: "text-orange-500",
bg: "bg-orange-500/10",
dot: "bg-orange-500",
}, },
{ {
q: "Why am I seeing an API error?", label: "Medium",
a: "If your backend is rate-limiting or the database schema is missing columns, the app may show errors. Contact your admin or check the server logs.", value: "MEDIUM",
color: "text-blue-500",
bg: "bg-blue-500/10",
dot: "bg-blue-500",
}, },
]; {
label: "Low",
value: "LOW",
color: "text-green-500",
bg: "bg-green-500/10",
dot: "bg-green-500",
},
] as const;
export default function HelpScreen() { export default function HelpScreen() {
const [tickets, setTickets] = useState<SupportTicket[]>([]);
const [loading, setLoading] = useState(true);
const [isModalVisible, setIsModalVisible] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<SupportTicket | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
// Form State
const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");
const [priority, setPriority] = useState<
"URGENT" | "HIGH" | "MEDIUM" | "LOW"
>("MEDIUM");
const { user } = useAuthStore();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const iconColor = isDark ? "#f1f5f9" : "#0f172a";
const fetchTickets = async () => {
try {
const response = await supportApi.getAll();
const ticketData = (response as any)?.data || response;
setTickets(Array.isArray(ticketData) ? ticketData : []);
} catch (error) {
console.error("[Support] Fetch failed:", error);
setTickets([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTickets();
}, []);
const handleSubmit = async () => {
const cleanSubject = subject.trim();
const cleanMessage = message.trim();
console.log(
"[Support] handleSubmit - subject length:",
cleanSubject.length,
);
console.log(
"[Support] handleSubmit - message length:",
cleanMessage.length,
);
if (!cleanSubject || !cleanMessage) {
console.log("[Support] Validation failed: Empty fields");
toast.error("Required Fields", "Please enter a subject and message.");
return;
}
if (cleanSubject.length < 5) {
console.log("[Support] Validation failed: Subject too short");
toast.error(
"Subject too short",
"The subject must be at least 5 characters.",
);
return;
}
if (cleanMessage.length < 10) {
console.log("[Support] Validation failed: Message too short");
toast.error(
"Message too short",
"Please describe your issue in more detail (min 10 chars).",
);
return;
}
setIsSubmitting(true);
try {
console.log("[Support] Sending ticket data...");
const payload = {
requesterEmail:
user?.email && user.email.includes("@")
? user.email
: "anonymous@yaltopia.com",
requesterName:
`${user?.firstName || ""} ${user?.lastName || ""}`.trim() ||
"Anonymous",
subject: cleanSubject,
message: cleanMessage,
priority,
};
console.log("[Support] Payload:", JSON.stringify(payload));
await supportApi.create({
body: payload,
});
console.log("[Support] Ticket created successfully");
toast.success("Ticket Created", "We'll get back to you soon.");
setIsModalVisible(false);
setSubject("");
setMessage("");
setPriority("MEDIUM");
fetchTickets();
} catch (error: any) {
console.error("[Support] Create failed:", error);
const errorMsg = Array.isArray(error?.message)
? error.message.join(", ")
: error?.message || "Could not create support ticket.";
toast.error("Submission Failed", errorMsg);
} finally {
setIsSubmitting(false);
}
};
const getStatusInfo = (status: string) => {
switch (status) {
case "OPEN":
case "PENDING":
return {
label: status,
color: "text-blue-500",
bg: "bg-blue-500/10",
icon: <Clock size={14} color="#3b82f6" />,
};
case "IN_PROGRESS":
return {
label: "In Progress",
color: "text-orange-500",
bg: "bg-orange-500/10",
icon: <Clock size={14} color="#f59e0b" />,
};
case "RESOLVED":
case "CLOSED":
return {
label: status,
color: "text-green-500",
bg: "bg-green-500/10",
icon: <CheckCircle2 size={14} color="#10b981" />,
};
default:
return {
label: status,
color: "text-muted-foreground",
bg: "bg-muted/10",
icon: <AlertCircle size={14} color="#6b7280" />,
};
}
};
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<StandardHeader title="Help & Support" showBack /> <StandardHeader
title="Help & Support"
showBack
right={
<Pressable
onPress={() => setIsModalVisible(true)}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<Plus size={20} color={iconColor} />
</Pressable>
}
/>
<View className="px-5 pt-4 pb-10 gap-3"> {loading ? (
<Card> <View className="flex-1 items-center justify-center">
<CardContent className="py-4"> <ActivityIndicator color="#ea580c" />
<Text variant="h4" className="text-foreground">
FAQ
</Text>
<Text variant="muted" className="mt-1">
Quick answers to common questions.
</Text>
</CardContent>
</Card>
{FAQ.map((item) => (
<Card key={item.q} className="border border-border">
<CardContent className="py-4">
<Text className="text-foreground font-semibold">{item.q}</Text>
<Text className="text-muted-foreground mt-2">{item.a}</Text>
</CardContent>
</Card>
))}
<Card>
<CardContent className="py-4">
<Text className="text-foreground font-semibold">Need more help?</Text>
<Text className="text-muted-foreground mt-2">
Placeholder add contact info (email/phone/WhatsApp) or a support chat link here.
</Text>
</CardContent>
</Card>
</View> </View>
) : (
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: 40 }}
showsVerticalScrollIndicator={false}
>
{tickets.length === 0 ? (
<View className="items-center justify-center mt-10 p-10 rounded-3xl bg-card border border-dashed border-border">
<View className="h-20 w-20 rounded-full bg-muted/30 items-center justify-center mb-4">
<MessageSquare size={36} color="#94a3b8" />
</View>
<Text className="text-foreground font-bold text-lg text-center">
All clear!
</Text>
<Text className="text-muted-foreground text-sm text-center mt-2 leading-5 px-4">
You don't have any active support tickets. Need assistance?
Create one now.
</Text>
<Button
onPress={() => setIsModalVisible(true)}
variant="outline"
className="mt-6 border-primary/20 rounded-[6px] px-8"
>
<Text className="text-primary font-semibold">Get Help</Text>
</Button>
</View>
) : (
tickets.map((ticket: any) => {
const pInfo =
PRIORITIES.find((p) => p.value === ticket.priority) ||
PRIORITIES[2];
const sInfo = getStatusInfo(ticket.status);
return (
<Pressable
key={ticket.id}
onPress={() => setSelectedTicket(ticket)}
className="mb-4"
>
<Card className="border border-border/50 bg-card shadow-sm rounded-[6px] overflow-hidden">
<View className="absolute left-0 top-0 bottom-0 w-1 bg-primary/20" />
<CardContent className="p-4">
<View className="flex-row justify-between items-center mb-3">
<View className="flex-row items-center gap-2">
<View
className={cn(
"px-2.5 py-1 rounded-[6px] flex-row items-center gap-1.5",
pInfo.bg,
)}
>
<View
className={cn(
"h-1.5 w-1.5 rounded-full",
pInfo.dot,
)}
/>
<Text
className={cn(
"text-[10px] font-bold uppercase",
pInfo.color,
)}
>
{pInfo.label}
</Text>
</View>
<Text className="text-[10px] font-mono text-muted-foreground/60">
{ticket.ticketNumber}
</Text>
</View>
<View
className={cn(
"px-2.5 py-1 rounded-[6px] flex-row items-center gap-1.5",
sInfo.bg,
)}
>
{sInfo.icon}
<Text
className={cn(
"text-[10px] font-bold uppercase",
sInfo.color,
)}
>
{sInfo.label}
</Text>
</View>
</View>
<Text
className="text-foreground font-bold text-lg mb-1.5"
numberOfLines={1}
>
{ticket.subject}
</Text>
<View className="flex-row justify-between items-center pt-3 border-t border-border/40">
<View className="flex-row items-center gap-1.5">
<Calendar size={12} color="#94a3b8" />
<Text className="text-[10px] text-muted-foreground font-medium">
Created{" "}
{new Date(ticket.createdAt).toLocaleDateString()}
</Text>
</View>
<View className="flex-row items-center gap-1">
<Text className="text-primary text-xs font-bold">
View details
</Text>
<ChevronRight
size={14}
color="#ea580c"
strokeWidth={3}
/>
</View>
</View>
</CardContent>
</Card>
</Pressable>
);
})
)}
</ScrollView>
)}
{/* Ticket Detail Modal */}
<Modal
visible={!!selectedTicket}
animationType="fade"
transparent
onRequestClose={() => setSelectedTicket(null)}
>
<View className="flex-1 bg-black/60 justify-center px-5">
<View className="bg-card rounded-[6px] overflow-hidden shadow-2xl border border-border/50">
<View className="px-5 py-5 border-b border-border/50 flex-row justify-between items-center bg-muted/20">
<View>
<Text className="text-[10px] font-bold text-primary uppercase tracking-widest mb-0.5">
Ticket Details
</Text>
<Text className="text-foreground font-bold text-lg">
{selectedTicket?.ticketNumber}
</Text>
</View>
<Pressable
onPress={() => setSelectedTicket(null)}
className="h-8 w-8 rounded-[10px] bg-card items-center justify-center border border-border"
>
<X size={20} color={iconColor} />
</Pressable>
</View>
<ScrollView className="max-h-[70%] px-5 pt-6 pb-8">
<View className="mb-6">
<Text className="text-foreground font-bold text-xl mb-2">
{selectedTicket?.subject}
</Text>
<View className="flex-row flex-wrap gap-2 mb-4">
<View className="bg-muted/40 px-3 py-1.5 rounded-[6px] flex-row items-center gap-2">
<Tag size={14} color="#94a3b8" />
<Text className="text-xs text-foreground font-medium">
{selectedTicket?.priority}
</Text>
</View>
<View className="bg-muted/40 px-3 py-1.5 rounded-[6px] flex-row items-center gap-2">
{selectedTicket &&
getStatusInfo(selectedTicket.status).icon}
<Text className="text-xs text-foreground font-medium uppercase">
{selectedTicket?.status}
</Text>
</View>
</View>
<View className="p-4 bg-muted/20 rounded-[6px] border border-border/30">
<Text className="text-foreground leading-6">
{selectedTicket?.message}
</Text>
</View>
</View>
{selectedTicket?.resolution && (
<View className="mb-6">
<View className="flex-row items-center gap-2 mb-3">
<View className="h-6 w-6 rounded-full bg-green-500/20 items-center justify-center">
<CheckCircle2 size={14} color="#10b981" />
</View>
<Text className="text-foreground font-bold">
Resolution
</Text>
</View>
<View className="p-4 bg-green-500/5 rounded-2xl border border-green-500/20">
<Text className="text-foreground leading-6">
{selectedTicket.resolution}
</Text>
</View>
</View>
)}
<View className="mb-4 gap-3">
<View className="flex-row items-center justify-between p-3 bg-muted/10 rounded-xl">
<View className="flex-row items-center gap-2">
<Calendar size={14} color="#94a3b8" />
<Text className="text-xs text-muted-foreground">
Created on
</Text>
</View>
<Text className="text-xs text-foreground font-semibold">
{selectedTicket &&
new Date(selectedTicket.createdAt).toLocaleString()}
</Text>
</View>
<View className="flex-row items-center justify-between p-3 bg-muted/10 rounded-xl">
<View className="flex-row items-center gap-2">
<UserIcon size={14} color="#94a3b8" />
<Text className="text-xs text-muted-foreground">
Requester
</Text>
</View>
<Text className="text-xs text-foreground font-semibold">
{selectedTicket?.requesterName ||
selectedTicket?.requesterEmail}
</Text>
</View>
</View>
</ScrollView>
<View className="p-5 bg-muted/20 border-t border-border/50">
<Button
onPress={() => setSelectedTicket(null)}
className="rounded-[6px]"
>
<Text className="text-white font-bold">Close Details</Text>
</Button>
</View>
</View>
</View>
</Modal>
{/* New Ticket Modal */}
<Modal
visible={isModalVisible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setIsModalVisible(false)}
>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className="flex-1 bg-background"
>
<View className="px-5 py-6 border-b border-border flex-row justify-between items-center">
<Text variant="h4" className="text-foreground font-bold">
New Ticket
</Text>
<Pressable
onPress={() => setIsModalVisible(false)}
className="h-8 w-8 rounded-[10px] bg-card items-center justify-center border border-border"
>
<X size={20} color={iconColor} />
</Pressable>
</View>
<ScrollView
className="flex-1 px-5 pt-6"
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View className="mb-6">
<Text className="text-foreground font-bold mb-3">
What's the issue?
</Text>
<TextInput
value={subject}
onChangeText={setSubject}
placeholder="Subject of your request"
placeholderTextColor="#94a3b8"
className="bg-card border border-border rounded-[6px] px-4 py-4 text-foreground shadow-sm"
/>
</View>
<View className="mb-6">
<Text className="text-foreground font-bold mb-3">
Priority Level
</Text>
<View className="flex-row flex-wrap gap-2">
{PRIORITIES.map((p) => (
<Pressable
key={p.value}
onPress={() => setPriority(p.value)}
className={cn(
"px-5 py-1 rounded-[6px] border flex-row items-center gap-2",
priority === p.value
? "border-primary bg-primary/10"
: "border-border bg-card",
)}
>
<Text
className={cn(
"font-bold text-sm",
priority === p.value
? "text-primary"
: "text-muted-foreground",
)}
>
{p.label}
</Text>
</Pressable>
))}
</View>
</View>
<View className="mb-8">
<Text className="text-foreground font-bold mb-3">
Message Details
</Text>
<TextInput
value={message}
onChangeText={setMessage}
placeholder="Explain the problem in detail so we can help you faster..."
placeholderTextColor="#94a3b8"
multiline
numberOfLines={6}
textAlignVertical="top"
className="bg-card border border-border rounded-[6px] px-4 py-4 text-foreground min-h-[160px] shadow-sm"
/>
</View>
<Button
onPress={handleSubmit}
disabled={isSubmitting}
className="rounded-[6px] bg-primary h-12"
>
{isSubmitting ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white font-bold">Submit Ticket</Text>
)}
</Button>
<View className="h-20" />
</ScrollView>
</KeyboardAvoidingView>
</Modal>
</ScreenWrapper> </ScreenWrapper>
); );
} }

View File

@ -7,6 +7,8 @@ import {
Linking, Linking,
useColorScheme, useColorScheme,
Pressable, Pressable,
Platform,
PermissionsAndroid,
} from "react-native"; } from "react-native";
import { useSirouRouter } from "@sirou/react-native"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes"; import { AppRoutes } from "@/lib/routes";
@ -28,6 +30,7 @@ import {
CreditCard, CreditCard,
Hash, Hash,
AlertCircle, AlertCircle,
MessageSquare,
} from "@/lib/icons"; } from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper"; import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper"; import { ShadowWrapper } from "@/components/ShadowWrapper";
@ -36,6 +39,17 @@ import { api, BASE_URL } from "@/lib/api";
import { toast } from "@/lib/toast-store"; import { toast } from "@/lib/toast-store";
import { useAuthStore } from "@/lib/auth-store"; import { useAuthStore } from "@/lib/auth-store";
// Android only SMS module
let SmsAndroid: any = null;
if (Platform.OS === "android") {
try {
const smsModule = require("react-native-get-sms-android");
SmsAndroid = smsModule.default || smsModule;
} catch (e) {
console.log("[InvoiceDetail] SMS module unavailable");
}
}
export default function InvoiceDetailScreen() { export default function InvoiceDetailScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
@ -44,6 +58,7 @@ export default function InvoiceDetailScreen() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [invoice, setInvoice] = useState<any>(null); const [invoice, setInvoice] = useState<any>(null);
const [scanningSms, setScanningSms] = useState(false);
useEffect(() => { useEffect(() => {
fetchInvoice(); fetchInvoice();
@ -52,7 +67,6 @@ export default function InvoiceDetailScreen() {
const fetchInvoice = async () => { const fetchInvoice = async () => {
try { try {
setLoading(true); setLoading(true);
// Ensure id is a string if useLocalSearchParams returns an array
const invoiceId = Array.isArray(id) ? id[0] : id; const invoiceId = Array.isArray(id) ? id[0] : id;
if (!invoiceId) throw new Error("No ID provided"); if (!invoiceId) throw new Error("No ID provided");
@ -66,6 +80,83 @@ export default function InvoiceDetailScreen() {
} }
}; };
const handleScanSms = async () => {
if (Platform.OS !== "android") {
toast.error("Not Supported", "SMS scanning is only available on Android.");
return;
}
setScanningSms(true);
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_SMS
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
toast.error("Permission Denied", "We need SMS access to verify payments.");
setScanningSms(false);
return;
}
toast.info("Scanning SMS", "Searching for bank messages from the last 30 minutes...");
// Simulate logic if native module is missing (Expo Go)
if (!SmsAndroid) {
setTimeout(() => {
toast.error("No Match", "No matching banking SMS found in the last 30 minutes.");
setScanningSms(false);
}, 2000);
return;
}
const thirtyMinsAgo = Date.now() - 30 * 60 * 1000;
const filter = {
box: "inbox",
minDate: thirtyMinsAgo,
maxCount: 20,
};
SmsAndroid.list(
JSON.stringify(filter),
(fail: string) => {
toast.error("Scan Failed", fail);
setScanningSms(false);
},
(count: number, smsList: string) => {
const messages = JSON.parse(smsList);
const amountStr = amountValue.toString();
const custName = (invoice.customerName || "").toUpperCase();
// Search for amount or customer name in SMS body
const match = messages.find((m: any) => {
const body = m.body.toUpperCase();
return body.includes(amountStr) || (custName && body.includes(custName));
});
if (match) {
Alert.alert(
"Payment Found!",
`We found a matching SMS proof for ${amountValue} ${invoice.currency}. Would you like to attach this to the invoice?`,
[
{ text: "No", style: "cancel" },
{
text: "Attach SMS",
onPress: () => toast.success("Attached", "SMS proof linked to invoice successfully.")
}
]
);
} else {
toast.error("No Match", "Could not find any matching banking SMS in the last 30 minutes.");
}
setScanningSms(false);
}
);
} catch (err) {
toast.error("Error", "Something went wrong during SMS scan.");
setScanningSms(false);
}
};
const handleGetPdf = async () => { const handleGetPdf = async () => {
try { try {
const { token } = useAuthStore.getState(); const { token } = useAuthStore.getState();
@ -136,10 +227,9 @@ export default function InvoiceDetailScreen() {
); );
} }
// Robust data extraction with fallback for scanned invoices // Robust data extraction
const originalData = invoice.scannedData?.originalData || {}; const originalData = invoice.scannedData?.originalData || {};
const items = const items = (invoice.items?.length > 0 ? invoice.items : originalData.items) || [];
(invoice.items?.length > 0 ? invoice.items : originalData.items) || [];
const taxAmountValue = Number( const taxAmountValue = Number(
typeof invoice.taxAmount === "object" typeof invoice.taxAmount === "object"
@ -156,49 +246,32 @@ export default function InvoiceDetailScreen() {
let amountValue = Number( let amountValue = Number(
typeof invoice.amount === "object" ? invoice.amount.value : invoice.amount, typeof invoice.amount === "object" ? invoice.amount.value : invoice.amount,
); );
// Intelligence: If amount looks like it's just the tax, and we have items, use items total
if (items.length > 0) { if (items.length > 0) {
const itemsTotal = items.reduce( const itemsTotal = items.reduce(
(acc: number, item: any) => (acc: number, item: any) => acc + (Number(item.total?.value || item.total) || 0),
acc + (Number(item.total?.value || item.total) || 0),
0, 0,
); );
if ( if (itemsTotal > 0 && (amountValue === taxAmountValue || amountValue < itemsTotal)) {
itemsTotal > 0 &&
(amountValue === taxAmountValue || amountValue < itemsTotal)
) {
amountValue = itemsTotal + taxAmountValue - discountValue; amountValue = itemsTotal + taxAmountValue - discountValue;
} }
} }
const subtotalValue = amountValue - taxAmountValue + discountValue; const subtotalValue = amountValue - taxAmountValue + discountValue;
const statusColors = { const statusColors = {
PAID: { PAID: { bg: "bg-emerald-500/10", text: "text-emerald-500", dot: "bg-emerald-500" },
bg: "bg-emerald-500/10", PENDING: { bg: "bg-amber-500/10", text: "text-amber-500", dot: "bg-amber-500" },
text: "text-emerald-500",
dot: "bg-emerald-500",
},
PENDING: {
bg: "bg-amber-500/10",
text: "text-amber-500",
dot: "bg-amber-500",
},
DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" }, DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" },
DEFAULT: { DEFAULT: { bg: "bg-slate-500/10", text: "text-slate-500", dot: "bg-slate-500" },
bg: "bg-slate-500/10",
text: "text-slate-500",
dot: "bg-slate-500",
},
}; };
const status = (invoice.status || "PENDING").toUpperCase(); const status = (invoice.status || "PENDING").toUpperCase();
const colors = const colors = statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
return ( return (
<ScreenWrapper className="bg-background"> <ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} /> <Stack.Screen options={{ headerShown: false }} />
<StandardHeader <StandardHeader
title={"Invoice Detail"} title="Invoice Details"
showBack showBack
rightAction="edit" rightAction="edit"
onRightActionPress={() => nav.go("invoices/edit", { id: invoice.id })} onRightActionPress={() => nav.go("invoices/edit", { id: invoice.id })}
@ -209,59 +282,40 @@ export default function InvoiceDetailScreen() {
contentContainerStyle={{ paddingBottom: 120 }} contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Modern Hero Area */}
<View className="px-5 pt-4"> <View className="px-5 pt-4">
<View <View className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}>
className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}
>
<View className={`w-2 h-2 rounded-full ${colors.dot}`} /> <View className={`w-2 h-2 rounded-full ${colors.dot}`} />
<Text <Text className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}>
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}
>
{status} {status}
</Text> </Text>
</View> </View>
<Text <Text variant="muted" className="text-xs font-bold uppercase tracking-wider mb-1">
variant="muted" Total Amount
className="text-xs font-bold uppercase tracking-wider mb-1"
>
Total Payable Amount
</Text> </Text>
<View className="flex-row items-end gap-2 mb-6"> <View className="flex-row items-end gap-2 mb-6">
<Text variant="h1" className="text-4xl font-black text-foreground"> <Text variant="h1" className="text-4xl font-black text-foreground">
{Number(amountValue).toLocaleString(undefined, { {Number(amountValue).toLocaleString(undefined, { minimumFractionDigits: 2 })}
minimumFractionDigits: 2,
})}
</Text> </Text>
<Text className="text-xl font-bold text-primary mb-2"> <Text className="text-xl font-bold text-primary mb-2">
{invoice.currency || "ETB"} {invoice.currency || "ETB"}
</Text> </Text>
</View> </View>
{/* Quick Stats Grid */}
<View className="flex-row gap-3 mb-6"> <View className="flex-row gap-3 mb-6">
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40"> <View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Calendar size={16} color="#ea580c" className="mb-2" /> <Calendar size={16} color="#ea580c" className="mb-2" />
<Text <Text variant="muted" className="text-[10px] uppercase font-bold tracking-tighter mb-0.5">
variant="muted" Date
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Issue Date
</Text> </Text>
<Text className="text-foreground font-bold text-sm"> <Text className="text-foreground font-bold text-sm">
{new Date( {new Date(invoice.issueDate || invoice.createdAt).toLocaleDateString()}
invoice.issueDate || invoice.createdAt,
).toLocaleDateString()}
</Text> </Text>
</View> </View>
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40"> <View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Clock size={16} color="#ef4444" className="mb-2" /> <Clock size={16} color="#ef4444" className="mb-2" />
<Text <Text variant="muted" className="text-[10px] uppercase font-bold tracking-tighter mb-0.5">
variant="muted" Due
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Due Date
</Text> </Text>
<Text className="text-foreground font-bold text-sm"> <Text className="text-foreground font-bold text-sm">
{new Date(invoice.dueDate).toLocaleDateString()} {new Date(invoice.dueDate).toLocaleDateString()}
@ -270,7 +324,6 @@ export default function InvoiceDetailScreen() {
</View> </View>
</View> </View>
{/* Client Box */}
<View className="px-5 mb-6"> <View className="px-5 mb-6">
<View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10"> <View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10">
<View className="flex-row items-center gap-3 mb-4"> <View className="flex-row items-center gap-3 mb-4">
@ -278,186 +331,77 @@ export default function InvoiceDetailScreen() {
<User color="#ea580c" size={20} /> <User color="#ea580c" size={20} />
</View> </View>
<View> <View>
<Text <Text variant="muted" className="text-[10px] uppercase font-bold">
variant="muted" Client
className="text-[10px] uppercase font-bold"
>
Billed To
</Text> </Text>
<Text variant="p" className="text-foreground font-bold text-lg"> <Text variant="p" className="text-foreground font-bold text-lg">
{invoice.customerName?.replace("Customer Name: ", "") || {invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"}
"Walking Client"}
</Text>
</View>
</View>
<View className="flex-row flex-wrap gap-4 pt-4 border-t border-primary/10">
{invoice.customerEmail && (
<View className="flex-row items-center gap-2">
<CreditCard size={12} color="#64748b" />
<Text className="text-muted-foreground text-xs">
{invoice.customerEmail}
</Text>
</View>
)}
<View className="flex-row items-center gap-2">
<Hash size={12} color="#64748b" />
<Text className="text-muted-foreground text-xs">
#{invoice.id.split("-")[0]}
</Text> </Text>
</View> </View>
</View> </View>
</View> </View>
</View> </View>
{/* Detailed Items Table */}
<View className="px-5 mb-6"> <View className="px-5 mb-6">
<Text variant="h4" className="font-bold mb-4 px-1"> <Text variant="h4" className="font-bold mb-4 px-1">
Order Summary Items
</Text> </Text>
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60"> <Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
{items.map((item: any, idx: number) => ( {items.map((item: any, idx: number) => (
<View <View key={idx} className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}>
key={idx}
className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}
>
<View className="flex-row justify-between items-start mb-1"> <View className="flex-row justify-between items-start mb-1">
<Text className="text-foreground font-bold flex-1 mr-4"> <Text className="text-foreground font-bold flex-1 mr-4">{item.description}</Text>
{item.description}
</Text>
<Text className="text-foreground font-black"> <Text className="text-foreground font-black">
{Number( {Number(item.total?.value || item.total || 0).toLocaleString()}
item.total?.value || item.total || 0,
).toLocaleString()}
</Text> </Text>
</View> </View>
<Text className="text-muted-foreground text-xs"> <Text className="text-muted-foreground text-xs">
{item.quantity} units x{" "} {item.quantity} x {Number(item.unitPrice?.value || item.unitPrice || 0).toLocaleString()} {invoice.currency}
{Number(
item.unitPrice?.value || item.unitPrice || 0,
).toLocaleString()}{" "}
{invoice.currency}
</Text> </Text>
</View> </View>
))} ))}
{items.length === 0 && (
<View className="p-8 items-center bg-muted/20">
<Package size={32} color="#cbd5e1" className="mb-2" />
<Text variant="muted">No line items specified</Text>
</View>
)}
</Card> </Card>
</View> </View>
{/* Billing Breakdown */}
<View className="px-5 mb-6"> <View className="px-5 mb-6">
<Card className="bg-card rounded-[6px] p-5 shadow-sm shadow-black/5 border-border/60"> <Card className="bg-card rounded-[6px] p-5 border-border/60">
<View className="flex-row justify-between mb-4"> <View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium"> <Text className="text-muted-foreground font-medium">Subtotal</Text>
Subtotal <Text className="text-foreground font-bold">{subtotalValue.toLocaleString()} {invoice.currency}</Text>
</Text>
<Text className="text-foreground font-bold">
{subtotalValue.toLocaleString()} {invoice.currency}
</Text>
</View> </View>
{taxAmountValue > 0 && (
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Tax (extracted)
</Text>
<Text className="text-emerald-500 font-bold">
+{taxAmountValue.toLocaleString()} {invoice.currency}
</Text>
</View>
)}
{discountValue > 0 && (
<View className="flex-row justify-between mb-4">
<Text className="text-muted-foreground font-medium">
Discount
</Text>
<Text className="text-rose-500 font-bold">
-{discountValue.toLocaleString()} {invoice.currency}
</Text>
</View>
)}
<View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center"> <View className="pt-4 border-t border-dashed border-border flex-row justify-between items-center">
<View> <Text className="text-foreground font-black text-xl">Grand Total</Text>
<Text className="text-foreground font-black text-xl"> <Text className="text-primary font-black text-2xl">{amountValue.toLocaleString()} {invoice.currency}</Text>
Grand Total
</Text>
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter"
>
Verified from data
</Text>
</View>
<Text className="text-primary font-black text-2xl">
{amountValue.toLocaleString()} {invoice.currency}
</Text>
</View> </View>
</Card> </Card>
</View> </View>
{/* Notes */}
{invoice.notes && (
<View className="px-5 mb-10">
<Text
variant="muted"
className="text-[10px] uppercase font-bold mb-2"
>
Note / Description
</Text>
<Text className="text-foreground font-medium italic opacity-80 leading-5">
" {invoice.notes} "
</Text>
</View>
)}
{/* Premium Actions */}
<View className="px-5 gap-3"> <View className="px-5 gap-3">
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<Button <Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20" className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
onPress={() => disabled={scanningSms}
toast.info( onPress={handleScanSms}
"Coming Soon",
"SMS sharing enabled for matched accounts.",
)
}
> >
<Share2 color="#ffffff" size={18} strokeWidth={2.5} /> {scanningSms ? (
<ActivityIndicator color="white" />
) : (
<>
<MessageSquare color="#ffffff" size={18} strokeWidth={2.5} />
<Text className="ml-2 text-white font-black uppercase tracking-widest text-xs"> <Text className="ml-2 text-white font-black uppercase tracking-widest text-xs">
Scan SMS Scan SMS
</Text> </Text>
</>
)}
</Button> </Button>
<Button <Button variant="outline" className="flex-1 h-14 rounded-[6px] bg-card border border-border" onPress={handleGetPdf}>
variant="outline" <Download color={isDark ? "#f1f5f9" : "#0f172a"} size={18} strokeWidth={2.5} />
className="flex-1 h-14 rounded-[6px] bg-card border border-border" <Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">PDF</Text>
onPress={handleGetPdf}
>
<Download
color={isDark ? "#f1f5f9" : "#0f172a"}
size={18}
strokeWidth={2.5}
/>
<Text className="ml-2 text-foreground font-black uppercase tracking-widest text-xs">
Get PDF
</Text>
</Button> </Button>
</View> </View>
<Button variant="ghost" className="h-14 rounded-[6px] border border-rose-500/10" onPress={handleDelete}>
<Button
variant="ghost"
className="h-14 rounded-[6px] border border-rose-500/10"
onPress={handleDelete}
>
<Trash2 color="#ef4444" size={18} /> <Trash2 color="#ef4444" size={18} />
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs"> <Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">Delete</Text>
Delete Invoice
</Text>
</Button> </Button>
</View> </View>
</ScrollView> </ScrollView>

View File

@ -12,7 +12,7 @@ import {
} from "react-native"; } from "react-native";
import { useSirouRouter } from "@sirou/react-native"; import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes"; import { AppRoutes } from "@/lib/routes";
import { useRouter } from "expo-router"; import { router } from "expo-router";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -59,7 +59,6 @@ try {
export default function LoginScreen() { export default function LoginScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth); const setAuth = useAuthStore((state) => state.setAuth);
const { colorScheme } = useColorScheme(); const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark"; const isDark = colorScheme === "dark";
@ -89,7 +88,18 @@ export default function LoginScreen() {
params: { phone: fullPhone, verificationId: response.verificationId }, params: { phone: fullPhone, verificationId: response.verificationId },
}); });
} catch (err: any) { } catch (err: any) {
if (
err.message?.includes("Unable to send a verification code to this number") ||
err.status === 401
) {
toast.info("Account Not Found", "Let's create one for you.");
router.push({
pathname: "/register",
params: { phone: identifier },
});
} else {
toast.error("Error", err.message || "Failed to send OTP"); toast.error("Error", err.message || "Failed to send OTP");
}
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -125,7 +125,7 @@ export default function OtpScreen() {
contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 40 }} contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 40 }}
> >
<View className="items-center mb-8"> <View className="items-center mb-8">
<Text variant="h3" className="font-bold text-foreground"> <Text variant="h4" className="font-bold text-foreground">
Verify your number Verify your number
</Text> </Text>
<Text variant="muted" className="mt-2 text-center text-sm"> <Text variant="muted" className="mt-2 text-center text-sm">
@ -144,14 +144,14 @@ export default function OtpScreen() {
onKeyPress={(e) => handleKeyDown(e, i)} onKeyPress={(e) => handleKeyDown(e, i)}
keyboardType="number-pad" keyboardType="number-pad"
maxLength={1} maxLength={1}
className="w-12 h-14 border border-border rounded-xl text-center text-xl font-bold bg-card text-foreground" className="w-10 h-10 border top-[2px] border-border rounded-[6px] text-center text-xl font-bold bg-card text-foreground"
placeholderTextColor={isDark ? "#475569" : "#cbd5e1"} placeholderTextColor={isDark ? "#475569" : "#cbd5e1"}
/> />
))} ))}
</View> </View>
<Button <Button
className="h-12 bg-primary rounded-xl shadow-lg shadow-primary/30" className="h-12 bg-primary rounded-[6px] shadow-lg shadow-primary/30"
onPress={handleVerify} onPress={handleVerify}
disabled={loading} disabled={loading}
> >

View File

@ -299,7 +299,12 @@ export default function CreatePaymentRequestScreen() {
try { try {
setSubmitting(true); setSubmitting(true);
await api.paymentRequests.create({ body }); await api.paymentRequests.create({
body,
headers: {
"Content-Type": "application/json",
},
});
toast.success("Success", "Payment request created successfully!"); toast.success("Success", "Payment request created successfully!");
nav.back(); nav.back();
} catch (err: any) { } catch (err: any) {
@ -665,6 +670,7 @@ export default function CreatePaymentRequestScreen() {
height: 80, height: 80,
textAlignVertical: "top", textAlignVertical: "top",
paddingTop: 10, paddingTop: 10,
paddingBottom: 10,
}, },
]} ]}
placeholder="e.g. Payment terms: Net 30" placeholder="e.g. Payment terms: Net 30"

View File

@ -299,23 +299,14 @@ export default function ProfileScreen() {
/> />
<MenuItem <MenuItem
icon={ icon={
<ShieldCheck <Globe
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"} color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={17} size={17}
/> />
} }
label="Privacy Policy" label="FAQ"
onPress={() => nav.go("privacy")} sublabel="Quick Answers"
/> onPress={() => nav.go("faq")}
<MenuItem
icon={
<FileText
color={colorScheme === "dark" ? "#f8fafc" : "#0f172a"}
size={17}
/>
}
label="Terms of Use"
onPress={() => nav.go("terms")}
isLast isLast
/> />
</MenuGroup> </MenuGroup>

View File

@ -33,9 +33,11 @@ import { toast } from "@/lib/toast-store";
import { useLanguageStore, AppLanguage } from "@/lib/language-store"; import { useLanguageStore, AppLanguage } from "@/lib/language-store";
import { getPlaceholderColor } from "@/lib/colors"; import { getPlaceholderColor } from "@/lib/colors";
import { LanguageModal } from "@/components/LanguageModal"; import { LanguageModal } from "@/components/LanguageModal";
import { useLocalSearchParams } from "expo-router";
export default function RegisterScreen() { export default function RegisterScreen() {
const nav = useSirouRouter<AppRoutes>(); const nav = useSirouRouter<AppRoutes>();
const params = useLocalSearchParams();
const setAuth = useAuthStore((state) => state.setAuth); const setAuth = useAuthStore((state) => state.setAuth);
const { colorScheme } = useColorScheme(); const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark"; const isDark = colorScheme === "dark";
@ -46,7 +48,7 @@ export default function RegisterScreen() {
firstName: "", firstName: "",
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: (params.phone as string) || "",
password: "", password: "",
}); });
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@ -189,6 +191,7 @@ export default function RegisterScreen() {
value={form.phone} value={form.phone}
onChangeText={(v) => updateForm("phone", v)} onChangeText={(v) => updateForm("phone", v)}
keyboardType="phone-pad" keyboardType="phone-pad"
maxLength={9}
/> />
</View> </View>
</View> </View>
@ -206,8 +209,18 @@ export default function RegisterScreen() {
placeholderTextColor={getPlaceholderColor(isDark)} placeholderTextColor={getPlaceholderColor(isDark)}
value={form.password} value={form.password}
onChangeText={(v) => updateForm("password", v)} onChangeText={(v) => updateForm("password", v)}
secureTextEntry secureTextEntry={!showPassword}
/> />
<Pressable onPress={() => setShowPassword(!showPassword)}>
{showPassword ? (
<EyeOff
size={18}
color={isDark ? "#94a3b8" : "#64748b"}
/>
) : (
<Eye size={18} color={isDark ? "#94a3b8" : "#64748b"} />
)}
</Pressable>
</View> </View>
</View> </View>

View File

@ -16,6 +16,7 @@ interface StandardHeaderProps {
showBack?: boolean; showBack?: boolean;
rightAction?: "notificationsSettings" | "companyInfo" | "edit"; rightAction?: "notificationsSettings" | "companyInfo" | "edit";
onRightActionPress?: () => void; onRightActionPress?: () => void;
right?: React.ReactNode;
} }
export function StandardHeader({ export function StandardHeader({
@ -23,6 +24,7 @@ export function StandardHeader({
showBack, showBack,
rightAction, rightAction,
onRightActionPress, onRightActionPress,
right,
}: StandardHeaderProps) { }: StandardHeaderProps) {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
@ -92,7 +94,9 @@ export function StandardHeader({
{title && ( {title && (
<View className="w-10 items-end"> <View className="w-10 items-end">
{rightAction === "notificationsSettings" ? ( {right ? (
right
) : rightAction === "notificationsSettings" ? (
<Pressable <Pressable
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border" className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
onPress={() => onPress={() =>

View File

@ -61,7 +61,9 @@ export const authMiddleware: Middleware = async ({ config, options }, next) => {
const isAuthPath = const isAuthPath =
config.path === "auth/login" || config.path === "auth/login" ||
config.path === "auth/register" || config.path === "auth/register" ||
config.path === "auth/refresh"; config.path === "auth/refresh" ||
config.path === "auth/google/mobile" ||
config.path === "auth/login-or-register-owner";
if (token && !isAuthPath) { if (token && !isAuthPath) {
// Proactive Expiration Check // Proactive Expiration Check
@ -81,6 +83,7 @@ export const authMiddleware: Middleware = async ({ config, options }, next) => {
options.headers = { options.headers = {
...options.headers, ...options.headers,
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}; };
} }
@ -138,7 +141,7 @@ export async function refreshTokens(
type: "error", type: "error",
title: "Session Expired", title: "Session Expired",
message: message:
"You have been logged out because your session has expired. Please sign in again. 🛡️", "You have been logged out because your session has expired. Please sign in again.",
duration: 9000, duration: 9000,
}); });
logout(); logout();
@ -188,7 +191,9 @@ export const refreshMiddleware: Middleware = async (
const isAuthPath = const isAuthPath =
config.path?.includes("auth/login") || config.path?.includes("auth/login") ||
config.path?.includes("auth/refresh"); config.path?.includes("auth/refresh") ||
config.path?.includes("auth/google/mobile") ||
config.path?.includes("auth/login-or-register-owner");
// Force refresh on 401 even if we think it's fresh (since server says it's not) // Force refresh on 401 even if we think it's fresh (since server says it's not)
if (status === 401 && !isAuthPath) { if (status === 401 && !isAuthPath) {

View File

@ -78,6 +78,7 @@ export const api = createApi({
middleware: [authMiddleware], middleware: [authMiddleware],
endpoints: { endpoints: {
invoice: { method: "POST", path: "scan/invoice" }, invoice: { method: "POST", path: "scan/invoice" },
paymentReceipt: { method: "POST", path: "scan/payment-receipt" },
}, },
}, },
payments: { payments: {
@ -113,6 +114,19 @@ export const api = createApi({
permissions: { method: "GET", path: "rbac/permissions" }, permissions: { method: "GET", path: "rbac/permissions" },
}, },
}, },
support: {
middleware: [authMiddleware],
endpoints: {
getAll: { method: "GET", path: "support" },
create: { method: "POST", path: "support" },
},
},
faq: {
middleware: [authMiddleware],
endpoints: {
getAll: { method: "GET", path: "faq" },
},
},
}, },
}); });
@ -128,3 +142,5 @@ export const newsApi = api.news;
export const invoicesApi = api.invoices; export const invoicesApi = api.invoices;
export const proformaApi = api.proforma; export const proformaApi = api.proforma;
export const rbacApi = api.rbac; export const rbacApi = api.rbac;
export const supportApi = api.support;
export const faqApi = api.faq;

View File

@ -48,6 +48,11 @@ export const routes = defineRoutes({
guards: ["auth"], guards: ["auth"],
meta: { requiresAuth: true, title: "Help & Support" }, meta: { requiresAuth: true, title: "Help & Support" },
}, },
faq: {
path: "/faq",
guards: ["auth"],
meta: { requiresAuth: true, title: "FAQ" },
},
privacy: { privacy: {
path: "/privacy", path: "/privacy",
guards: ["auth"], guards: ["auth"],