Add local iOS release pipeline, fix shortcuts, and improve invoice UX.
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>
This commit is contained in:
@@ -14,6 +14,13 @@ export function formatDate(date: Date | string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function formatShortDate(date: Date | string) {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | string) {
|
||||
return new Date(date).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AppRouter } from "beenvoice/server/api/root";
|
||||
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
||||
|
||||
export type InvoicePdfPreviewInput = inferRouterInputs<AppRouter>["invoices"]["previewPdf"];
|
||||
type InvoiceDetail = NonNullable<inferRouterOutputs<AppRouter>["invoices"]["getById"]>;
|
||||
|
||||
export type InvoicePdfFormFields = {
|
||||
invoiceNumber: string;
|
||||
invoicePrefix?: string | null;
|
||||
businessId?: string | null;
|
||||
clientId: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status?: "draft" | "sent" | "paid";
|
||||
notes?: string | null;
|
||||
emailMessage?: string | null;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
items: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number | string;
|
||||
rate: number | string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function buildPreviewPdfInput(fields: InvoicePdfFormFields): InvoicePdfPreviewInput | null {
|
||||
if (!fields.clientId.trim()) return null;
|
||||
|
||||
const items = fields.items.map((item) => ({
|
||||
date: item.date,
|
||||
description: item.description.trim() || "Service",
|
||||
hours: Number(item.hours) || 0,
|
||||
rate: Number(item.rate) || 0,
|
||||
}));
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return {
|
||||
invoiceNumber: fields.invoiceNumber.trim() || "DRAFT",
|
||||
invoicePrefix: fields.invoicePrefix?.trim() || "#",
|
||||
businessId: fields.businessId?.trim() || "",
|
||||
clientId: fields.clientId,
|
||||
issueDate: fields.issueDate,
|
||||
dueDate: fields.dueDate,
|
||||
status: fields.status ?? "draft",
|
||||
notes: fields.notes?.trim() ?? "",
|
||||
emailMessage: fields.emailMessage?.trim() ?? "",
|
||||
taxRate: fields.taxRate,
|
||||
currency: fields.currency,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPreviewPdfInputFromInvoice(invoice: InvoiceDetail): InvoicePdfPreviewInput {
|
||||
return {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoicePrefix: invoice.invoicePrefix ?? "#",
|
||||
businessId: invoice.businessId ?? "",
|
||||
clientId: invoice.clientId,
|
||||
issueDate: new Date(invoice.issueDate),
|
||||
dueDate: new Date(invoice.dueDate),
|
||||
status: invoice.status as "draft" | "sent" | "paid",
|
||||
notes: invoice.notes ?? "",
|
||||
emailMessage: invoice.emailMessage ?? "",
|
||||
taxRate: invoice.taxRate,
|
||||
currency: invoice.currency ?? "USD",
|
||||
items: invoice.items.map((item) => ({
|
||||
date: new Date(item.date),
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function canPreviewPdfInput(input: InvoicePdfPreviewInput | null): input is InvoicePdfPreviewInput {
|
||||
if (!input?.clientId) return false;
|
||||
return input.items.every((item) => item.description.trim().length > 0);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
import type { ParsedShortcut } from "@/lib/shortcuts";
|
||||
|
||||
const STORAGE_KEY = "beenvoice:pending-shortcut";
|
||||
|
||||
let memory: ParsedShortcut | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
export async function enqueueShortcut(shortcut: ParsedShortcut): Promise<void> {
|
||||
memory = shortcut;
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(shortcut));
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function peekPendingShortcut(): Promise<ParsedShortcut | null> {
|
||||
if (memory) return memory;
|
||||
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
memory = JSON.parse(raw) as ParsedShortcut;
|
||||
return memory;
|
||||
} catch {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPendingShortcut(): Promise<void> {
|
||||
memory = null;
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function subscribeShortcutQueue(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export async function hasPendingShortcut(): Promise<boolean> {
|
||||
return (await peekPendingShortcut()) != null;
|
||||
}
|
||||
+20
-8
@@ -13,6 +13,25 @@ function queryParam(value: string | string[] | undefined): string {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function normalizeShortcutPath(hostname: string | null, path: string | null): string | null {
|
||||
const host = (hostname ?? "").replace(/^\/+|\/+$/g, "");
|
||||
const segment = (path ?? "").replace(/^\/+|\/+$/g, "");
|
||||
|
||||
if (host === "shortcuts" && segment) {
|
||||
return segment;
|
||||
}
|
||||
|
||||
const combined = [host, segment].filter(Boolean).join("/");
|
||||
const match = combined.match(/(?:^|\/)shortcuts\/(clock-in|clock-out)$/);
|
||||
if (match?.[1]) return match[1];
|
||||
|
||||
if (combined === "clock-in" || combined === "clock-out") {
|
||||
return combined;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse `beenvoice://shortcuts/clock-in` and related URLs from Shortcuts / Siri. */
|
||||
export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut | null {
|
||||
if (!url) return null;
|
||||
@@ -27,14 +46,7 @@ export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut
|
||||
return { action: "open-timer", title: "", clientId: "" };
|
||||
}
|
||||
|
||||
let shortcutAction: string | null = null;
|
||||
if (host === "shortcuts" && path) {
|
||||
shortcutAction = path;
|
||||
} else {
|
||||
const match = path.match(/^shortcuts\/(clock-in|clock-out)$/);
|
||||
shortcutAction = match?.[1] ?? null;
|
||||
}
|
||||
|
||||
const shortcutAction = normalizeShortcutPath(host, path);
|
||||
if (shortcutAction === "clock-in" || shortcutAction === "clock-out") {
|
||||
return {
|
||||
action: shortcutAction,
|
||||
|
||||
Reference in New Issue
Block a user