355b14faef
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,
|
|
},
|
|
});
|