Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
738 lines
19 KiB
TypeScript
738 lines
19 KiB
TypeScript
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,
|
|
},
|
|
});
|