t
This commit is contained in:
parent
db5ac60987
commit
1b5e82c895
16
AGENTS.md
Normal file
16
AGENTS.md
Normal 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 -->
|
||||||
11
app.json
11
app.json
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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
127
app/faq.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
608
app/help.tsx
608
app/help.tsx
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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={() =>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
16
lib/api.ts
16
lib/api.ts
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user