Enable App Store builds without EAS, iOS 18 App Intents plugins, and signing fixes for distribution export. Add mobile invoice PDF preview, compact line items, and more reliable shortcut deep-link handling. Co-authored-by: Cursor <cursoragent@cursor.com>
181 lines
4.5 KiB
TypeScript
181 lines
4.5 KiB
TypeScript
import { useMemo } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Pressable,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
type StyleProp,
|
|
type ViewStyle,
|
|
} from "react-native";
|
|
import { WebView } from "react-native-webview";
|
|
|
|
import { fonts, radii, spacing } from "@/constants/theme";
|
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
|
import {
|
|
canPreviewPdfInput,
|
|
type InvoicePdfPreviewInput,
|
|
} from "@/lib/invoice-pdf-input";
|
|
import type { ThemeColors } from "@/lib/theme-palette";
|
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
|
import { api } from "@/lib/trpc";
|
|
|
|
type InvoicePdfPreviewProps = {
|
|
input: InvoicePdfPreviewInput | null;
|
|
height?: number;
|
|
style?: StyleProp<ViewStyle>;
|
|
};
|
|
|
|
function buildPdfHtml(contentType: string, base64: string) {
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0" />
|
|
<style>
|
|
html, body { margin: 0; height: 100%; background: #525659; }
|
|
embed { width: 100%; height: 100%; border: 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<embed src="data:${contentType};base64,${base64}" type="application/pdf" />
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export function InvoicePdfPreview({
|
|
input,
|
|
height = 560,
|
|
style,
|
|
}: InvoicePdfPreviewProps) {
|
|
const { colors } = useAppTheme();
|
|
const styles = useThemedStyles(createPreviewStyles);
|
|
const enabled = canPreviewPdfInput(input);
|
|
|
|
const { data, isLoading, isFetching, error, refetch } =
|
|
api.invoices.previewPdf.useQuery(input!, {
|
|
enabled,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: 5_000,
|
|
});
|
|
|
|
const html = useMemo(() => {
|
|
if (!data?.base64) return null;
|
|
return buildPdfHtml(data.contentType, data.base64);
|
|
}, [data]);
|
|
|
|
if (!enabled) {
|
|
return (
|
|
<View style={[styles.frame, { height }, style]}>
|
|
<Text style={styles.placeholder}>
|
|
Select a client and add a description to every line item to preview the
|
|
PDF.
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (isLoading && !html) {
|
|
return (
|
|
<View style={[styles.frame, styles.centered, { height }, style]}>
|
|
<ActivityIndicator color={colors.primary} />
|
|
<Text style={styles.loadingText}>Generating preview…</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<View style={[styles.frame, styles.centered, { height }, style]}>
|
|
<Text style={styles.errorText}>{error.message}</Text>
|
|
<Pressable accessibilityRole="button" onPress={() => void refetch()}>
|
|
<Text style={[styles.retry, { color: colors.primary }]}>Try again</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!html) {
|
|
return (
|
|
<View style={[styles.frame, styles.centered, { height }, style]}>
|
|
<Text style={styles.placeholder}>PDF preview will appear here.</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={[styles.wrapper, style]}>
|
|
{isFetching ? (
|
|
<View style={styles.refreshing}>
|
|
<ActivityIndicator size="small" color={colors.primary} />
|
|
</View>
|
|
) : null}
|
|
<View style={[styles.frame, { height }]}>
|
|
<WebView
|
|
originWhitelist={["*"]}
|
|
source={{ html }}
|
|
style={styles.webview}
|
|
scrollEnabled
|
|
showsVerticalScrollIndicator
|
|
showsHorizontalScrollIndicator={false}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const createPreviewStyles = (colors: ThemeColors) =>
|
|
StyleSheet.create({
|
|
wrapper: {
|
|
gap: spacing.xs,
|
|
},
|
|
frame: {
|
|
overflow: "hidden",
|
|
borderRadius: radii.lg,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.muted,
|
|
},
|
|
webview: {
|
|
flex: 1,
|
|
backgroundColor: "transparent",
|
|
},
|
|
centered: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: spacing.lg,
|
|
gap: spacing.sm,
|
|
},
|
|
placeholder: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
color: colors.mutedForeground,
|
|
textAlign: "center",
|
|
padding: spacing.lg,
|
|
},
|
|
loadingText: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 13,
|
|
color: colors.mutedForeground,
|
|
},
|
|
errorText: {
|
|
fontFamily: fonts.body,
|
|
fontSize: 14,
|
|
color: colors.destructive,
|
|
textAlign: "center",
|
|
},
|
|
retry: {
|
|
fontFamily: fonts.bodySemiBold,
|
|
fontSize: 14,
|
|
},
|
|
refreshing: {
|
|
position: "absolute",
|
|
top: spacing.sm,
|
|
right: spacing.sm,
|
|
zIndex: 2,
|
|
borderRadius: radii.pill,
|
|
backgroundColor: colors.card,
|
|
padding: spacing.xs,
|
|
},
|
|
});
|