import { Ionicons } from "@expo/vector-icons"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Animated, Dimensions, Keyboard, Modal, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import QRCode from "react-native-qrcode-svg"; import { Colors } from "@/constants/colors"; const C = Colors.dark; const POLL_INTERVAL = 3000; const SCREEN_WIDTH = Dimensions.get("window").width; const QR_SIZE = Math.min(SCREEN_WIDTH - 80, 240); const BASE_URL = process.env.EXPO_PUBLIC_DOMAIN ?? ""; function getApiBase(): string { let domain = BASE_URL; if (!domain) domain = "localhost:8080"; domain = domain.replace(/\/$/, ""); if (!/^https?:\/\//.test(domain)) { const proto = domain.startsWith("localhost") ? "http" : "https"; domain = `${proto}://${domain}`; } return domain; } type JobState = | "awaiting_eval_payment" | "evaluating" | "rejected" | "awaiting_work_payment" | "executing" | "complete" | "failed"; type InvoiceInfo = { paymentRequest: string; amountSats: number; }; type JobStatus = { jobId: string; state: JobState; evalInvoice?: InvoiceInfo; workInvoice?: InvoiceInfo; result?: string; reason?: string; errorMessage?: string; }; type CreateJobResponse = { jobId: string; evalInvoice: InvoiceInfo; }; type EstimateResponse = { estimatedCostSats: number; estimatedCostUsd: number; btcPriceUsd: number; }; const STATE_LABELS: Record = { awaiting_eval_payment: "Awaiting eval payment", evaluating: "Evaluating your request...", rejected: "Request rejected", awaiting_work_payment: "Awaiting work payment", executing: "Executing job...", complete: "Complete!", failed: "Job failed", }; const STATE_ICONS: Record = { awaiting_eval_payment: "flash-outline", evaluating: "hourglass-outline", rejected: "close-circle-outline", awaiting_work_payment: "flash-outline", executing: "cog-outline", complete: "checkmark-circle-outline", failed: "alert-circle-outline", }; type Props = { visible: boolean; onClose: () => void; }; export function JobSubmissionSheet({ visible, onClose }: Props) { const insets = useSafeAreaInsets(); const [prompt, setPrompt] = useState(""); const [estimate, setEstimate] = useState(null); const [estimateLoading, setEstimateLoading] = useState(false); const [estimateError, setEstimateError] = useState(""); const [jobId, setJobId] = useState(null); const [jobStatus, setJobStatus] = useState(null); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(""); const [resultExpanded, setResultExpanded] = useState(false); const slideAnim = useRef(new Animated.Value(0)).current; const pollRef = useRef | null>(null); // Slide animation useEffect(() => { Animated.timing(slideAnim, { toValue: visible ? 1 : 0, duration: 300, useNativeDriver: true, }).start(); }, [visible, slideAnim]); // Polling for job status useEffect(() => { if (!jobId) return; const poll = async () => { try { const res = await fetch(`${getApiBase()}/api/jobs/${jobId}`); if (!res.ok) return; const data = (await res.json()) as JobStatus; setJobStatus(data); // Stop polling on terminal states if ( data.state === "complete" || data.state === "failed" || data.state === "rejected" ) { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } } } catch { // ignore poll errors } }; // Immediately fetch once void poll(); pollRef.current = setInterval(poll, POLL_INTERVAL); return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; }, [jobId]); const resetState = useCallback(() => { setPrompt(""); setEstimate(null); setEstimateError(""); setJobId(null); setJobStatus(null); setSubmitting(false); setSubmitError(""); setResultExpanded(false); if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }, []); const handleClose = useCallback(() => { resetState(); onClose(); }, [onClose, resetState]); const handleEstimate = useCallback(async () => { if (!prompt.trim()) return; Keyboard.dismiss(); setEstimateLoading(true); setEstimateError(""); setEstimate(null); try { const res = await fetch(`${getApiBase()}/api/jobs/estimate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request: prompt.trim() }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Request failed" })); setEstimateError( (err as { error?: string }).error ?? `HTTP ${res.status}` ); return; } const data = (await res.json()) as EstimateResponse; setEstimate(data); } catch (e) { setEstimateError( e instanceof Error ? e.message : "Failed to get estimate" ); } finally { setEstimateLoading(false); } }, [prompt]); const handleSubmit = useCallback(async () => { if (!prompt.trim()) return; Keyboard.dismiss(); setSubmitting(true); setSubmitError(""); try { const res = await fetch(`${getApiBase()}/api/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request: prompt.trim() }), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Request failed" })); setSubmitError( (err as { error?: string }).error ?? `HTTP ${res.status}` ); return; } const data = (await res.json()) as CreateJobResponse; setJobId(data.jobId); setJobStatus({ jobId: data.jobId, state: "awaiting_eval_payment", evalInvoice: data.evalInvoice, }); } catch (e) { setSubmitError( e instanceof Error ? e.message : "Failed to submit job" ); } finally { setSubmitting(false); } }, [prompt]); // Determine which invoice to show const activeInvoice: InvoiceInfo | undefined = jobStatus?.state === "awaiting_work_payment" ? jobStatus.workInvoice : jobStatus?.state === "awaiting_eval_payment" ? jobStatus.evalInvoice : undefined; const isTerminal = jobStatus?.state === "complete" || jobStatus?.state === "failed" || jobStatus?.state === "rejected"; const isPolling = jobId != null && !isTerminal; const renderContent = () => { // === JOB IN PROGRESS / COMPLETE === if (jobId && jobStatus) { return ( {/* Status badge */} ["name"] } size={20} color={ jobStatus.state === "complete" ? C.jobCompleted : jobStatus.state === "failed" || jobStatus.state === "rejected" ? C.error : C.jobStarted } /> {STATE_LABELS[jobStatus.state] ?? jobStatus.state} {isPolling && ( )} {/* Invoice QR */} {activeInvoice && ( {jobStatus.state === "awaiting_eval_payment" ? "Pay eval invoice" : "Pay work invoice"} {activeInvoice.amountSats} sats Scan with your Lightning wallet )} {/* Rejection reason */} {jobStatus.state === "rejected" && jobStatus.reason && ( {jobStatus.reason} )} {/* Error message */} {jobStatus.state === "failed" && jobStatus.errorMessage && ( {jobStatus.errorMessage} )} {/* Result */} {jobStatus.state === "complete" && jobStatus.result && ( setResultExpanded((v) => !v)} > Result {jobStatus.result} )} {/* New job button for terminal states */} {isTerminal && ( Submit another job )} ); } // === INPUT FORM === return ( What should Timmy do? {/* Estimate result */} {estimate && ( Estimated cost: {estimate.estimatedCostSats} sats (~$ {estimate.estimatedCostUsd.toFixed(4)}) )} {estimateError ? ( {estimateError} ) : null} {submitError ? ( {submitError} ) : null} {/* Action buttons */} {estimateLoading ? ( ) : ( <> Estimate )} {submitting ? ( ) : ( <> Submit Job )} ); }; return ( {/* Handle bar */} {/* Header */} Submit Job {renderContent()} ); } const styles = StyleSheet.create({ overlay: { flex: 1, justifyContent: "flex-end", }, overlayBackdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0,0,0,0.5)", }, sheet: { backgroundColor: C.surface, borderTopLeftRadius: 20, borderTopRightRadius: 20, maxHeight: "85%", borderWidth: 1, borderBottomWidth: 0, borderColor: C.border, }, handleRow: { alignItems: "center", paddingTop: 10, paddingBottom: 4, }, handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: C.textMuted, }, sheetHeader: { flexDirection: "row", alignItems: "center", paddingHorizontal: 20, paddingVertical: 12, gap: 10, borderBottomWidth: 1, borderBottomColor: C.border, }, sheetTitle: { flex: 1, fontSize: 18, fontFamily: "Inter_600SemiBold", color: C.text, }, sheetScroll: { flex: 1, }, sheetScrollContent: { padding: 20, gap: 16, }, inputLabel: { fontSize: 14, fontFamily: "Inter_500Medium", color: C.textSecondary, }, textInput: { backgroundColor: C.surfaceElevated, borderRadius: 12, borderWidth: 1, borderColor: C.border, color: C.text, fontFamily: "Inter_400Regular", fontSize: 15, padding: 14, minHeight: 100, ...(Platform.OS === "web" ? { outlineStyle: "none" as unknown as undefined } : {}), }, estimateCard: { flexDirection: "row", alignItems: "center", gap: 8, backgroundColor: C.surfaceElevated, borderRadius: 10, padding: 12, borderWidth: 1, borderColor: C.jobStarted + "44", }, estimateText: { fontSize: 14, fontFamily: "Inter_500Medium", color: C.jobStarted, flex: 1, }, errorCard: { flexDirection: "row", alignItems: "flex-start", gap: 8, backgroundColor: C.error + "18", borderRadius: 10, padding: 12, borderWidth: 1, borderColor: C.error + "44", }, errorCardText: { fontSize: 13, fontFamily: "Inter_400Regular", color: C.error, flex: 1, }, buttonRow: { flexDirection: "row", gap: 12, }, estimateButton: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6, backgroundColor: C.surfaceElevated, borderRadius: 12, paddingVertical: 14, borderWidth: 1, borderColor: C.accent + "44", }, estimateButtonText: { fontSize: 15, fontFamily: "Inter_600SemiBold", color: C.accent, }, submitButton: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6, backgroundColor: C.accent, borderRadius: 12, paddingVertical: 14, }, submitButtonText: { fontSize: 15, fontFamily: "Inter_600SemiBold", color: "#fff", }, buttonDisabled: { opacity: 0.5, }, statusRow: { flexDirection: "row", alignItems: "center", gap: 8, backgroundColor: C.surfaceElevated, borderRadius: 10, padding: 12, borderWidth: 1, borderColor: C.border, }, statusText: { flex: 1, fontSize: 14, fontFamily: "Inter_500Medium", color: C.text, }, qrContainer: { alignItems: "center", gap: 12, }, qrLabel: { fontSize: 14, fontFamily: "Inter_500Medium", color: C.textSecondary, }, qrWrapper: { backgroundColor: "#FFFFFF", padding: 16, borderRadius: 12, }, satsLabel: { fontSize: 20, fontFamily: "Inter_700Bold", color: C.jobStarted, }, invoiceHint: { fontSize: 12, fontFamily: "Inter_400Regular", color: C.textMuted, }, resultCard: { backgroundColor: C.surfaceElevated, borderRadius: 12, padding: 14, borderWidth: 1, borderColor: C.jobCompleted + "44", gap: 10, }, resultHeader: { flexDirection: "row", alignItems: "center", gap: 8, }, resultTitle: { flex: 1, fontSize: 15, fontFamily: "Inter_600SemiBold", color: C.text, }, resultText: { fontSize: 14, fontFamily: "Inter_400Regular", color: C.text, lineHeight: 20, }, newJobButton: { alignItems: "center", paddingVertical: 14, backgroundColor: C.surfaceElevated, borderRadius: 12, borderWidth: 1, borderColor: C.border, }, newJobButtonText: { fontSize: 15, fontFamily: "Inter_500Medium", color: C.accent, }, });