Move production to beenvoice.app with migrated accounts, refreshed auth and timer UX, and expanded invoice flows.

Official URL migration preserves sessions, shortcuts prefs, and last clock-in client; auth screens match web with legal links; time clock and invoice editor/send flows are updated for the new domain and UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 03:40:48 -04:00
co-authored by Cursor
parent e17c4c6854
commit 6762a9bff3
60 changed files with 2544 additions and 1091 deletions
+66 -118
View File
@@ -1,14 +1,12 @@
import { router, Stack, useLocalSearchParams } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { Alert, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
InvoiceEditorSectionTabs,
type InvoiceEditorSection,
} from "@/components/invoices/InvoiceEditorSectionTabs";
import { InvoiceViewChips, type InvoiceViewSection } from "@/components/invoices/InvoiceViewChips";
import { InvoicePdfPreview } from "@/components/invoices/InvoicePdfPreview";
import { InvoiceTotals } from "@/components/invoices/InvoiceTotals";
import { InvoiceDetailActions } from "@/components/invoices/InvoiceDetailActions";
import { LoadingScreen } from "@/components/LoadingScreen";
import { StatusBadge } from "@/components/StatusBadge";
import { Button } from "@/components/ui/Button";
@@ -24,12 +22,11 @@ import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { api } from "@/lib/trpc";
export default function InvoiceDetailScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createInvoiceDetailStyles);
const { id } = useLocalSearchParams<{ id: string }>();
const utils = api.useUtils();
const scrollPadding = useTabBarScrollPadding();
const [section, setSection] = useState<InvoiceEditorSection>("edit");
const [section, setSection] = useState<InvoiceViewSection>("details");
const invoiceQuery = api.invoices.getById.useQuery(
{ id: id ?? "" },
@@ -45,16 +42,6 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Update failed", err.message),
});
const sendInvoice = api.email.sendInvoice.useMutation({
onSuccess: (data) => {
Alert.alert("Invoice sent", data.message);
void utils.invoices.getById.invalidate({ id: id ?? "" });
void utils.invoices.getAll.invalidate();
void utils.dashboard.getStats.invalidate();
},
onError: (err) => Alert.alert("Could not send invoice", err.message),
});
const sendPaymentReminder = api.invoices.sendReminder.useMutation({
onSuccess: () => {
Alert.alert("Reminder sent", "Payment reminder emailed to the client.");
@@ -63,6 +50,12 @@ export default function InvoiceDetailScreen() {
onError: (err) => Alert.alert("Could not send reminder", err.message),
});
const invoice = invoiceQuery.data;
const previewInput = useMemo(
() => (invoice ? buildPreviewPdfInputFromInvoice(invoice) : null),
[invoice],
);
if (!id) {
return <LoadingScreen message="Invalid invoice" />;
}
@@ -85,17 +78,12 @@ export default function InvoiceDetailScreen() {
);
}
const invoice = invoiceQuery.data;
const status = getInvoiceStatus(invoice);
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
const taxAmount = subtotal * (invoice.taxRate / 100);
const clientEmail = invoice.client?.email?.trim() ?? "";
const previewInput = useMemo(
() => buildPreviewPdfInputFromInvoice(invoice),
[invoice],
);
function promptSendInvoice() {
function openSendScreen() {
if (!clientEmail) {
Alert.alert(
"No client email",
@@ -103,18 +91,14 @@ export default function InvoiceDetailScreen() {
);
return;
}
Alert.alert(
status === "draft" ? "Send invoice" : "Resend invoice",
`Email this invoice to ${clientEmail}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Send",
onPress: () => sendInvoice.mutate({ invoiceId: invoice.id }),
},
],
);
if (invoice.items.length === 0) {
Alert.alert(
"No line items",
"Add line items or clock time to this invoice before sending.",
);
return;
}
router.push(`/(app)/invoices/send/${invoice.id}`);
}
function promptPaymentReminder() {
@@ -159,25 +143,7 @@ export default function InvoiceDetailScreen() {
return (
<AppBackground>
<Stack.Screen
options={{
headerBackTitle: "Invoices",
headerRight: () =>
status !== "paid" ? (
<Pressable
accessibilityRole="button"
hitSlop={8}
onPress={promptSendInvoice}
disabled={sendInvoice.isPending}
style={({ pressed }) => pressed && styles.headerPressed}
>
<Text style={[styles.headerAction, { color: colors.primary }]}>
{status === "draft" ? "Send" : "Resend"}
</Text>
</Pressable>
) : null,
}}
/>
<Stack.Screen options={{ headerBackTitle: "Invoices" }} />
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
@@ -201,11 +167,12 @@ export default function InvoiceDetailScreen() {
</Text>
</Card>
<InvoiceEditorSectionTabs
value={section}
onChange={setSection}
editLabel="Details"
previewLabel="PDF"
<InvoiceViewChips
section={section}
onSectionChange={setSection}
status={status}
onEdit={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
onSend={openSendScreen}
/>
{section === "preview" ? (
@@ -215,6 +182,8 @@ export default function InvoiceDetailScreen() {
) : (
<>
<Card title="Details">
<DetailRow label="Business" value={invoice.business?.name ?? "—"} />
<DetailRow label="Client" value={invoice.client?.name ?? "Client"} />
<DetailRow label="Issued" value={formatDate(invoice.issueDate)} />
<DetailRow label="Due" value={formatDate(invoice.dueDate)} />
<DetailRow label="Currency" value={invoice.currency} />
@@ -234,20 +203,27 @@ export default function InvoiceDetailScreen() {
</Card>
<Card title="Line items">
{invoice.items.map((item) => (
<View key={item.id} style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
{invoice.items.length === 0 ? (
<Text style={styles.emptyLines}>
No line items yet. Clock time to this invoice from the Timer tab, or edit to
add lines manually.
</Text>
) : (
invoice.items.map((item) => (
<View key={item.id} style={styles.lineItem}>
<View style={styles.lineMeta}>
<Text style={styles.lineDescription}>{item.description}</Text>
<Text style={styles.lineSub}>
{formatDate(item.date)} · {item.hours}h ×{" "}
{formatCurrency(item.rate, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
<Text style={styles.lineAmount}>
{formatCurrency(item.amount, invoice.currency)}
</Text>
</View>
))}
))
)}
<InvoiceTotals
subtotal={formatCurrency(subtotal, invoice.currency)}
taxLabel={invoice.taxRate > 0 ? `Tax (${invoice.taxRate}%)` : undefined}
@@ -264,43 +240,19 @@ export default function InvoiceDetailScreen() {
</Card>
) : null}
<View style={styles.actions}>
{status !== "paid" ? (
<Button
title={status === "draft" ? "Send invoice" : "Resend invoice"}
onPress={promptSendInvoice}
loading={sendInvoice.isPending}
/>
) : null}
{status === "sent" || status === "overdue" ? (
<Button
title="Send payment reminder"
variant="secondary"
onPress={promptPaymentReminder}
loading={sendPaymentReminder.isPending}
/>
) : null}
<Button
title="Edit invoice"
variant="secondary"
onPress={() => router.push(`/(app)/invoices/edit/${invoice.id}`)}
/>
<Button
title="Update status"
variant="ghost"
onPress={() => promptStatusChange(status)}
loading={updateStatus.isPending}
/>
<Button
title="Track time to this invoice"
variant="ghost"
onPress={() =>
router.push(
`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`,
)
}
/>
</View>
<InvoiceDetailActions
status={status}
clientEmail={clientEmail}
onPaymentReminder={
status === "sent" || status === "overdue" ? promptPaymentReminder : undefined
}
paymentReminderLoading={sendPaymentReminder.isPending}
onUpdateStatus={() => promptStatusChange(status)}
updateStatusLoading={updateStatus.isPending}
onTrackTime={() =>
router.push(`/(app)/timer?clientId=${invoice.clientId}&invoiceId=${invoice.id}`)
}
/>
</>
)}
</ScrollView>
@@ -398,22 +350,18 @@ const createInvoiceDetailStyles = (colors: ThemeColors, _isDark: boolean) =>
color: colors.foreground,
fontSize: 14,
},
emptyLines: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
color: colors.mutedForeground,
},
notes: {
fontFamily: fonts.body,
color: colors.foreground,
fontSize: 14,
lineHeight: 20,
},
actions: {
gap: spacing.sm,
},
headerAction: {
fontFamily: fonts.bodySemiBold,
fontSize: 16,
},
headerPressed: {
opacity: 0.65,
},
errorBox: {
flex: 1,
justifyContent: "center",