Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
This commit was merged in pull request #88.
This commit is contained in:
737
artifacts/mobile/components/JobSubmissionSheet.tsx
Normal file
737
artifacts/mobile/components/JobSubmissionSheet.tsx
Normal file
@@ -0,0 +1,737 @@
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<EstimateResponse | null>(null);
|
||||
const [estimateLoading, setEstimateLoading] = useState(false);
|
||||
const [estimateError, setEstimateError] = useState("");
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [jobStatus, setJobStatus] = useState<JobStatus | null>(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<ReturnType<typeof setInterval> | 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 (
|
||||
<ScrollView
|
||||
style={styles.sheetScroll}
|
||||
contentContainerStyle={styles.sheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Status badge */}
|
||||
<View style={styles.statusRow}>
|
||||
<Ionicons
|
||||
name={
|
||||
(STATE_ICONS[jobStatus.state] ?? "help-outline") as React.ComponentProps<typeof Ionicons>["name"]
|
||||
}
|
||||
size={20}
|
||||
color={
|
||||
jobStatus.state === "complete"
|
||||
? C.jobCompleted
|
||||
: jobStatus.state === "failed" || jobStatus.state === "rejected"
|
||||
? C.error
|
||||
: C.jobStarted
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
jobStatus.state === "complete" && { color: C.jobCompleted },
|
||||
(jobStatus.state === "failed" ||
|
||||
jobStatus.state === "rejected") && { color: C.error },
|
||||
]}
|
||||
>
|
||||
{STATE_LABELS[jobStatus.state] ?? jobStatus.state}
|
||||
</Text>
|
||||
{isPolling && (
|
||||
<ActivityIndicator size="small" color={C.accent} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Invoice QR */}
|
||||
{activeInvoice && (
|
||||
<View style={styles.qrContainer}>
|
||||
<Text style={styles.qrLabel}>
|
||||
{jobStatus.state === "awaiting_eval_payment"
|
||||
? "Pay eval invoice"
|
||||
: "Pay work invoice"}
|
||||
</Text>
|
||||
<View style={styles.qrWrapper}>
|
||||
<QRCode
|
||||
value={activeInvoice.paymentRequest}
|
||||
size={QR_SIZE}
|
||||
backgroundColor="#FFFFFF"
|
||||
color="#000000"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.satsLabel}>
|
||||
{activeInvoice.amountSats} sats
|
||||
</Text>
|
||||
<Text style={styles.invoiceHint}>
|
||||
Scan with your Lightning wallet
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Rejection reason */}
|
||||
{jobStatus.state === "rejected" && jobStatus.reason && (
|
||||
<View style={styles.errorCard}>
|
||||
<Ionicons name="close-circle" size={16} color={C.error} />
|
||||
<Text style={styles.errorCardText}>{jobStatus.reason}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{jobStatus.state === "failed" && jobStatus.errorMessage && (
|
||||
<View style={styles.errorCard}>
|
||||
<Ionicons name="alert-circle" size={16} color={C.error} />
|
||||
<Text style={styles.errorCardText}>
|
||||
{jobStatus.errorMessage}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{jobStatus.state === "complete" && jobStatus.result && (
|
||||
<Pressable
|
||||
style={styles.resultCard}
|
||||
onPress={() => setResultExpanded((v) => !v)}
|
||||
>
|
||||
<View style={styles.resultHeader}>
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={18}
|
||||
color={C.jobCompleted}
|
||||
/>
|
||||
<Text style={styles.resultTitle}>Result</Text>
|
||||
<Ionicons
|
||||
name={resultExpanded ? "chevron-up" : "chevron-down"}
|
||||
size={16}
|
||||
color={C.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={styles.resultText}
|
||||
numberOfLines={resultExpanded ? undefined : 6}
|
||||
>
|
||||
{jobStatus.result}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* New job button for terminal states */}
|
||||
{isTerminal && (
|
||||
<Pressable style={styles.newJobButton} onPress={resetState}>
|
||||
<Text style={styles.newJobButtonText}>Submit another job</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// === INPUT FORM ===
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.sheetScroll}
|
||||
contentContainerStyle={styles.sheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<Text style={styles.inputLabel}>What should Timmy do?</Text>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
placeholder="Describe your job..."
|
||||
placeholderTextColor={C.textMuted}
|
||||
value={prompt}
|
||||
onChangeText={setPrompt}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
maxLength={2000}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Estimate result */}
|
||||
{estimate && (
|
||||
<View style={styles.estimateCard}>
|
||||
<Ionicons name="flash" size={16} color={C.jobStarted} />
|
||||
<Text style={styles.estimateText}>
|
||||
Estimated cost: {estimate.estimatedCostSats} sats (~$
|
||||
{estimate.estimatedCostUsd.toFixed(4)})
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{estimateError ? (
|
||||
<View style={styles.errorCard}>
|
||||
<Ionicons name="alert-circle" size={16} color={C.error} />
|
||||
<Text style={styles.errorCardText}>{estimateError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{submitError ? (
|
||||
<View style={styles.errorCard}>
|
||||
<Ionicons name="alert-circle" size={16} color={C.error} />
|
||||
<Text style={styles.errorCardText}>{submitError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Action buttons */}
|
||||
<View style={styles.buttonRow}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.estimateButton,
|
||||
(!prompt.trim() || estimateLoading) && styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleEstimate}
|
||||
disabled={!prompt.trim() || estimateLoading}
|
||||
>
|
||||
{estimateLoading ? (
|
||||
<ActivityIndicator size="small" color={C.accent} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="calculator-outline" size={18} color={C.accent} />
|
||||
<Text style={styles.estimateButtonText}>Estimate</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.submitButton,
|
||||
(!prompt.trim() || submitting) && styles.buttonDisabled,
|
||||
]}
|
||||
onPress={handleSubmit}
|
||||
disabled={!prompt.trim() || submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="flash" size={18} color="#fff" />
|
||||
<Text style={styles.submitButtonText}>Submit Job</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.overlayBackdrop} onPress={handleClose} />
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, 16),
|
||||
transform: [
|
||||
{
|
||||
translateY: slideAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [600, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Handle bar */}
|
||||
<View style={styles.handleRow}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.sheetHeader}>
|
||||
<Ionicons name="flash" size={22} color={C.jobStarted} />
|
||||
<Text style={styles.sheetTitle}>Submit Job</Text>
|
||||
<Pressable
|
||||
onPress={handleClose}
|
||||
hitSlop={12}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close"
|
||||
>
|
||||
<Ionicons name="close" size={24} color={C.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{renderContent()}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user