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": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.yaltopia.ticketapp"
|
||||
"bundleIdentifier": "com.yaltopia.ticketapp",
|
||||
"infoPlist": {
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLSchemes": [
|
||||
"com.googleusercontent.apps.1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
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 { 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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={() =>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
16
lib/api.ts
16
lib/api.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user