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": {
"supportsTablet": true,
"bundleIdentifier": "com.yaltopia.ticketapp"
"bundleIdentifier": "com.yaltopia.ticketapp",
"infoPlist": {
"CFBundleURLTypes": [
{
"CFBundleURLSchemes": [
"com.googleusercontent.apps.1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi"
]
}
]
}
},
"android": {
"adaptiveIcon": {

View File

@ -5,10 +5,19 @@ import {
Platform,
ActivityIndicator,
Alert,
Image,
StyleSheet,
} from "react-native";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { X, Zap, Camera as CameraIcon, ScanLine } from "@/lib/icons";
import {
X,
Zap,
Camera as CameraIcon,
ScanLine,
Check,
RefreshCw,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { CameraView, useCameraPermissions } from "expo-camera";
import { useSirouRouter } from "@sirou/react-native";
@ -24,7 +33,9 @@ export default function ScanScreen() {
const nav = useSirouRouter<AppRoutes>();
const [permission, requestPermission] = useCameraPermissions();
const [torch, setTorch] = useState(false);
const [scanType, setScanType] = useState<"invoice" | "receipt">("invoice");
const [scanning, setScanning] = useState(false);
const [previewUri, setPreviewUri] = useState<string | null>(null);
const cameraRef = useRef<CameraView>(null);
const navigation = useNavigation();
const token = useAuthStore((s) => s.token);
@ -56,69 +67,86 @@ export default function ScanScreen() {
};
}, [navigation]);
const handleScan = async () => {
const handleCapture = async () => {
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);
try {
// 1. Capture the photo
const photo = await cameraRef.current.takePictureAsync({
quality: 0.85,
base64: false,
});
const label = scanType === "invoice" ? "invoice" : "receipt";
toast.info("Processing...", `Uploading ${label} image for AI extraction.`);
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
// Build multipart form data with the image file (binary)
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", {
uri: photo.uri,
name: "invoice.jpg",
type: "image/jpeg",
uri: Platform.OS === "android" ? previewUri : previewUri.replace("file://", ""),
name: fileName,
type: type,
} as any);
// 3. POST to /api/v1/scan/invoice
const response = await fetch(`${BASE_URL}scan/invoice`, {
const endpoint =
scanType === "invoice" ? "scan/invoice" : "scan/payment-receipt";
const response = await fetch(`${BASE_URL}${endpoint}`, {
method: "POST",
headers: {
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,
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || "Scan failed.");
const err = await response.json().catch(() => ({ message: "Scan processing failed." }));
throw new Error(err.message || "Extraction failed.");
}
const scanResult = await response.json();
console.log("[Scan] Extracted invoice data:", scanResult);
console.log(`[Scan] Extracted ${label} data:`, scanResult);
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 invoicePayload = {
invoiceNumber: ocr.invoiceNumber || `INV-${Date.now()}`,
customerName: ocr.customerName?.trim() || "Unknown Customer",
invoiceNumber: ocr.invoiceNumber || `DOC-${Date.now()}`,
customerName: ocr.customerName?.trim() || "Unknown Entity",
customerEmail: ocr.customerEmail || "",
customerPhone: ocr.customerPhone || "",
amount: ocr.totalAmount || ocr.subtotalAmount || 0,
currency: ocr.currency || "ETB",
type: "SALES",
type: scanType === "invoice" ? "SALES" : "EXPENSE",
status: "DRAFT",
issueDate: ocr.issueDate
? new Date(ocr.issueDate).toISOString()
: new Date().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.",
taxAmount: ocr.taxAmount || 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({
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) {
nav.go(`invoices/${createResponse.id}`);
} else {
nav.go("(tabs)/payments");
}
} catch (err: any) {
console.error("[Scan] Error:", err);
toast.error(
"Scan Failed",
err.message || "Could not process the invoice.",
);
console.error("[Scan] Processing Error:", err);
toast.error("Processing Failed", err.message || "Document extraction failed.");
} finally {
setScanning(false);
}
@ -196,6 +222,59 @@ export default function ScanScreen() {
return (
<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
ref={cameraRef}
style={{ flex: 1 }}
@ -225,34 +304,54 @@ export default function ScanScreen() {
</View>
{/* 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-[280px] h-[480px] border border-white/10 rounded-2xl" />
<View className="w-[280px] h-[380px] border border-white/10 rounded-2xl" />
</View>
<Text className="text-white font-bold mt-8 uppercase tracking-[3px] text-xs">
Align Invoice Within Frame
</Text>
</View>
{/* Capture Button */}
<View className="items-center pb-10 gap-4">
{/* Capture Button & Tabs */}
<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
onPress={handleScan}
disabled={scanning}
className="h-20 w-20 rounded-full bg-primary items-center justify-center border-4 border-white/30"
onPress={() => setScanType("invoice")}
className={`px-6 py-2 rounded-xl ${scanType === "invoice" ? "bg-primary" : "bg-transparent"}`}
>
<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} />
)}
</Pressable>
<Text className="text-white/50 text-[10px] font-black uppercase tracking-widest">
{scanning ? "Extracting Data..." : "Tap to Scan"}
{"Tap to Capture"}
</Text>
</View>
</View>
</View>
</CameraView>
)}
</View>
);
}

View File

@ -27,11 +27,17 @@ import { useFonts } from "expo-font";
import { api } from "@/lib/api";
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 isAuthed = useAuthStore((s) => s.isAuthenticated);
const params = useLocalSearchParams();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const sirou = useSirouRouter();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
@ -40,7 +46,42 @@ function BackupGuard() {
useEffect(() => {
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;
}
@ -86,52 +127,6 @@ function SessionHeartbeat() {
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() {
const { colorScheme } = useColorScheme();
useRestoreTheme();
@ -190,6 +185,7 @@ export default function RootLayout() {
>
<View className="flex-1 bg-background">
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
<GlobalGuard />
<Stack
screenOptions={{
headerShown: false,
@ -229,6 +225,7 @@ export default function RootLayout() {
options={{ title: "Notification settings" }}
/>
<Stack.Screen name="help" options={{ headerShown: false }} />
<Stack.Screen name="faq" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="history" options={{ headerShown: false }} />
@ -260,8 +257,6 @@ export default function RootLayout() {
options={{ headerShown: false }}
/>
</Stack>
<SirouBridge />
<BackupGuard />
<SessionHeartbeat />
<PortalHost />
<Toast />

View File

@ -85,6 +85,7 @@ export default function CompanyScreen() {
className="flex-1 ml-3 text-foreground"
placeholder="Search workers..."
placeholderTextColor={getPlaceholderColor(isDark)}
placeholderClassName="pb-4"
value={searchQuery}
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 { StandardHeader } from "@/components/StandardHeader";
import { Text } from "@/components/ui/text";
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?",
a: "Go to Profile > Appearance and choose Light, Dark, or System.",
label: "Urgent",
value: "URGENT",
color: "text-red-500",
bg: "bg-red-500/10",
dot: "bg-red-500",
},
{
q: "Where can I find my invoices and proformas?",
a: "Use the tabs on the bottom navigation to browse Invoices/Payments and Proformas.",
label: "High",
value: "HIGH",
color: "text-orange-500",
bg: "bg-orange-500/10",
dot: "bg-orange-500",
},
{
q: "Why am I seeing an API error?",
a: "If your backend is rate-limiting or the database schema is missing columns, the app may show errors. Contact your admin or check the server logs.",
label: "Medium",
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() {
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 (
<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">
<Card>
<CardContent className="py-4">
<Text variant="h4" className="text-foreground">
FAQ
</Text>
<Text variant="muted" className="mt-1">
Quick answers to common questions.
</Text>
</CardContent>
</Card>
{FAQ.map((item) => (
<Card key={item.q} className="border border-border">
<CardContent className="py-4">
<Text className="text-foreground font-semibold">{item.q}</Text>
<Text className="text-muted-foreground mt-2">{item.a}</Text>
</CardContent>
</Card>
))}
<Card>
<CardContent className="py-4">
<Text className="text-foreground font-semibold">Need more help?</Text>
<Text className="text-muted-foreground mt-2">
Placeholder add contact info (email/phone/WhatsApp) or a support chat link here.
</Text>
</CardContent>
</Card>
{loading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#ea580c" />
</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>
);
}

View File

@ -7,6 +7,8 @@ import {
Linking,
useColorScheme,
Pressable,
Platform,
PermissionsAndroid,
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
@ -28,6 +30,7 @@ import {
CreditCard,
Hash,
AlertCircle,
MessageSquare,
} from "@/lib/icons";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { ShadowWrapper } from "@/components/ShadowWrapper";
@ -36,6 +39,17 @@ import { api, BASE_URL } from "@/lib/api";
import { toast } from "@/lib/toast-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() {
const nav = useSirouRouter<AppRoutes>();
const { id } = useLocalSearchParams();
@ -44,6 +58,7 @@ export default function InvoiceDetailScreen() {
const [loading, setLoading] = useState(true);
const [invoice, setInvoice] = useState<any>(null);
const [scanningSms, setScanningSms] = useState(false);
useEffect(() => {
fetchInvoice();
@ -52,7 +67,6 @@ export default function InvoiceDetailScreen() {
const fetchInvoice = async () => {
try {
setLoading(true);
// Ensure id is a string if useLocalSearchParams returns an array
const invoiceId = Array.isArray(id) ? id[0] : id;
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 () => {
try {
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 items =
(invoice.items?.length > 0 ? invoice.items : originalData.items) || [];
const items = (invoice.items?.length > 0 ? invoice.items : originalData.items) || [];
const taxAmountValue = Number(
typeof invoice.taxAmount === "object"
@ -156,49 +246,32 @@ export default function InvoiceDetailScreen() {
let amountValue = Number(
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) {
const itemsTotal = items.reduce(
(acc: number, item: any) =>
acc + (Number(item.total?.value || item.total) || 0),
(acc: number, item: any) => acc + (Number(item.total?.value || item.total) || 0),
0,
);
if (
itemsTotal > 0 &&
(amountValue === taxAmountValue || amountValue < itemsTotal)
) {
if (itemsTotal > 0 && (amountValue === taxAmountValue || amountValue < itemsTotal)) {
amountValue = itemsTotal + taxAmountValue - discountValue;
}
}
const subtotalValue = amountValue - taxAmountValue + discountValue;
const statusColors = {
PAID: {
bg: "bg-emerald-500/10",
text: "text-emerald-500",
dot: "bg-emerald-500",
},
PENDING: {
bg: "bg-amber-500/10",
text: "text-amber-500",
dot: "bg-amber-500",
},
PAID: { bg: "bg-emerald-500/10", 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" },
DEFAULT: {
bg: "bg-slate-500/10",
text: "text-slate-500",
dot: "bg-slate-500",
},
DEFAULT: { bg: "bg-slate-500/10", text: "text-slate-500", dot: "bg-slate-500" },
};
const status = (invoice.status || "PENDING").toUpperCase();
const colors =
statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
const colors = statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT;
return (
<ScreenWrapper className="bg-background">
<Stack.Screen options={{ headerShown: false }} />
<StandardHeader
title={"Invoice Detail"}
title="Invoice Details"
showBack
rightAction="edit"
onRightActionPress={() => nav.go("invoices/edit", { id: invoice.id })}
@ -209,59 +282,40 @@ export default function InvoiceDetailScreen() {
contentContainerStyle={{ paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
>
{/* Modern Hero Area */}
<View className="px-5 pt-4">
<View
className={`self-start px-3 py-1 rounded-full flex-row items-center gap-2 ${colors.bg} mb-4`}
>
<View 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}`} />
<Text
className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}
>
<Text className={`text-[10px] font-black uppercase tracking-widest ${colors.text}`}>
{status}
</Text>
</View>
<Text
variant="muted"
className="text-xs font-bold uppercase tracking-wider mb-1"
>
Total Payable Amount
<Text variant="muted" className="text-xs font-bold uppercase tracking-wider mb-1">
Total Amount
</Text>
<View className="flex-row items-end gap-2 mb-6">
<Text variant="h1" className="text-4xl font-black text-foreground">
{Number(amountValue).toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
{Number(amountValue).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Text>
<Text className="text-xl font-bold text-primary mb-2">
{invoice.currency || "ETB"}
</Text>
</View>
{/* Quick Stats Grid */}
<View className="flex-row gap-3 mb-6">
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Calendar size={16} color="#ea580c" className="mb-2" />
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Issue Date
<Text variant="muted" className="text-[10px] uppercase font-bold tracking-tighter mb-0.5">
Date
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(
invoice.issueDate || invoice.createdAt,
).toLocaleDateString()}
{new Date(invoice.issueDate || invoice.createdAt).toLocaleDateString()}
</Text>
</View>
<View className="flex-1 bg-card rounded-[6px] p-4 border border-border/40">
<Clock size={16} color="#ef4444" className="mb-2" />
<Text
variant="muted"
className="text-[10px] uppercase font-bold tracking-tighter mb-0.5"
>
Due Date
<Text variant="muted" className="text-[10px] uppercase font-bold tracking-tighter mb-0.5">
Due
</Text>
<Text className="text-foreground font-bold text-sm">
{new Date(invoice.dueDate).toLocaleDateString()}
@ -270,7 +324,6 @@ export default function InvoiceDetailScreen() {
</View>
</View>
{/* Client Box */}
<View className="px-5 mb-6">
<View className="bg-primary/5 rounded-[6px] p-5 border border-primary/10">
<View className="flex-row items-center gap-3 mb-4">
@ -278,186 +331,77 @@ export default function InvoiceDetailScreen() {
<User color="#ea580c" size={20} />
</View>
<View>
<Text
variant="muted"
className="text-[10px] uppercase font-bold"
>
Billed To
<Text variant="muted" className="text-[10px] uppercase font-bold">
Client
</Text>
<Text variant="p" className="text-foreground font-bold text-lg">
{invoice.customerName?.replace("Customer Name: ", "") ||
"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]}
{invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"}
</Text>
</View>
</View>
</View>
</View>
{/* Detailed Items Table */}
<View className="px-5 mb-6">
<Text variant="h4" className="font-bold mb-4 px-1">
Order Summary
Items
</Text>
<Card className="bg-card rounded-[6px] overflow-hidden border-border/60">
{items.map((item: any, idx: number) => (
<View
key={idx}
className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}
>
<View key={idx} className={`p-4 ${idx !== items.length - 1 ? "border-b border-border/40" : ""}`}>
<View className="flex-row justify-between items-start mb-1">
<Text className="text-foreground font-bold flex-1 mr-4">
{item.description}
</Text>
<Text className="text-foreground font-bold flex-1 mr-4">{item.description}</Text>
<Text className="text-foreground font-black">
{Number(
item.total?.value || item.total || 0,
).toLocaleString()}
{Number(item.total?.value || item.total || 0).toLocaleString()}
</Text>
</View>
<Text className="text-muted-foreground text-xs">
{item.quantity} units x{" "}
{Number(
item.unitPrice?.value || item.unitPrice || 0,
).toLocaleString()}{" "}
{invoice.currency}
{item.quantity} x {Number(item.unitPrice?.value || item.unitPrice || 0).toLocaleString()} {invoice.currency}
</Text>
</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>
</View>
{/* Billing Breakdown */}
<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">
<Text className="text-muted-foreground font-medium">
Subtotal
</Text>
<Text className="text-foreground font-bold">
{subtotalValue.toLocaleString()} {invoice.currency}
</Text>
<Text className="text-muted-foreground font-medium">Subtotal</Text>
<Text className="text-foreground font-bold">{subtotalValue.toLocaleString()} {invoice.currency}</Text>
</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>
<Text className="text-foreground font-black text-xl">
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>
<Text className="text-foreground font-black text-xl">Grand Total</Text>
<Text className="text-primary font-black text-2xl">{amountValue.toLocaleString()} {invoice.currency}</Text>
</View>
</Card>
</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="flex-row gap-3">
<Button
className="flex-1 h-14 rounded-[6px] bg-primary shadow-lg shadow-primary/20"
onPress={() =>
toast.info(
"Coming Soon",
"SMS sharing enabled for matched accounts.",
)
}
disabled={scanningSms}
onPress={handleScanSms}
>
<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">
Scan SMS
</Text>
</>
)}
</Button>
<Button
variant="outline"
className="flex-1 h-14 rounded-[6px] bg-card border border-border"
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 variant="outline" className="flex-1 h-14 rounded-[6px] bg-card border border-border" 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">PDF</Text>
</Button>
</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} />
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">
Delete Invoice
</Text>
<Text className="ml-2 text-rose-500 font-bold uppercase tracking-widest text-xs">Delete</Text>
</Button>
</View>
</ScrollView>

View File

@ -12,7 +12,7 @@ import {
} from "react-native";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
import { useRouter } from "expo-router";
import { router } from "expo-router";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import {
@ -59,7 +59,6 @@ try {
export default function LoginScreen() {
const nav = useSirouRouter<AppRoutes>();
const router = useRouter();
const setAuth = useAuthStore((state) => state.setAuth);
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
@ -89,7 +88,18 @@ export default function LoginScreen() {
params: { phone: fullPhone, verificationId: response.verificationId },
});
} 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");
}
} finally {
setLoading(false);
}

View File

@ -125,7 +125,7 @@ export default function OtpScreen() {
contentContainerStyle={{ paddingHorizontal: 24, paddingTop: 40 }}
>
<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
</Text>
<Text variant="muted" className="mt-2 text-center text-sm">
@ -144,14 +144,14 @@ export default function OtpScreen() {
onKeyPress={(e) => handleKeyDown(e, i)}
keyboardType="number-pad"
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"}
/>
))}
</View>
<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}
disabled={loading}
>

View File

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

View File

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

View File

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

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

View File

@ -61,7 +61,9 @@ export const authMiddleware: Middleware = async ({ config, options }, next) => {
const isAuthPath =
config.path === "auth/login" ||
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) {
// Proactive Expiration Check
@ -81,6 +83,7 @@ export const authMiddleware: Middleware = async ({ config, options }, next) => {
options.headers = {
...options.headers,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
}
@ -138,7 +141,7 @@ export async function refreshTokens(
type: "error",
title: "Session Expired",
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,
});
logout();
@ -188,7 +191,9 @@ export const refreshMiddleware: Middleware = async (
const isAuthPath =
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)
if (status === 401 && !isAuthPath) {

View File

@ -78,6 +78,7 @@ export const api = createApi({
middleware: [authMiddleware],
endpoints: {
invoice: { method: "POST", path: "scan/invoice" },
paymentReceipt: { method: "POST", path: "scan/payment-receipt" },
},
},
payments: {
@ -113,6 +114,19 @@ export const api = createApi({
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 proformaApi = api.proforma;
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"],
meta: { requiresAuth: true, title: "Help & Support" },
},
faq: {
path: "/faq",
guards: ["auth"],
meta: { requiresAuth: true, title: "FAQ" },
},
privacy: {
path: "/privacy",
guards: ["auth"],