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
+9 -1
View File
@@ -1,7 +1,15 @@
import Constants from "expo-constants";
/** Production API used by default (App Store review + production builds). */
export const DEFAULT_API_URL = "https://beenvoice.soconnor.dev";
export const DEFAULT_API_URL = "https://beenvoice.app";
export const OFFICIAL_SERVER_HOST = new URL(DEFAULT_API_URL).host;
export const OFFICIAL_SERVER_PLACEHOLDER = `${OFFICIAL_SERVER_HOST} or localhost:3000`;
export function invalidServerUrlMessage(): string {
return `Enter a valid server URL (e.g. ${OFFICIAL_SERVER_PLACEHOLDER})`;
}
let runtimeOverride: string | null = null;
+6 -1
View File
@@ -54,7 +54,7 @@ export type LineItemInput = {
};
export function validateLineItems(items: LineItemInput[]): string | null {
if (items.length === 0) return "Add at least one line item";
if (items.length === 0) return null;
for (const item of items) {
if (!isRequiredString(item.description)) return "Each line needs a description";
@@ -64,3 +64,8 @@ export function validateLineItems(items: LineItemInput[]): string | null {
return null;
}
export function validateLineItemsForSend(items: LineItemInput[]): string | null {
if (items.length === 0) return "Add at least one line item before sending";
return validateLineItems(items);
}
+3 -1
View File
@@ -1,5 +1,7 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { invalidServerUrlMessage } from "@/lib/config";
const STORAGE_KEY = "beenvoice:instance-url";
export function normalizeInstanceUrl(input: string): string | null {
@@ -31,7 +33,7 @@ export async function loadStoredInstanceUrl(): Promise<string | null> {
export async function saveStoredInstanceUrl(url: string): Promise<string> {
const normalized = normalizeInstanceUrl(url);
if (!normalized) {
throw new Error("Enter a valid server URL (e.g. beenvoice.app or localhost:3000)");
throw new Error(invalidServerUrlMessage());
}
await AsyncStorage.setItem(STORAGE_KEY, normalized);
return normalized;
+17
View File
@@ -0,0 +1,17 @@
type BusinessOption = {
id: string;
isDefault?: boolean | null;
};
export function pickDefaultBusinessId(businesses: BusinessOption[] | undefined): string {
if (!businesses?.length) return "";
return businesses.find((business) => business.isDefault)?.id ?? businesses[0]!.id;
}
export function resolveInvoiceBusinessId(
explicitId: string | null | undefined,
businesses: BusinessOption[] | undefined,
): string {
if (explicitId?.trim()) return explicitId.trim();
return pickDefaultBusinessId(businesses);
}
+1 -1
View File
@@ -56,7 +56,7 @@ export function buildPreviewPdfInputFromInvoice(invoice: InvoiceDetail): Invoice
return {
invoiceNumber: invoice.invoiceNumber,
invoicePrefix: invoice.invoicePrefix ?? "#",
businessId: invoice.businessId ?? "",
businessId: invoice.businessId ?? invoice.business?.id ?? "",
clientId: invoice.clientId,
issueDate: new Date(invoice.issueDate),
dueDate: new Date(invoice.dueDate),
+109
View File
@@ -0,0 +1,109 @@
import {
authStoragePrefix,
buildAccountId,
loadAccounts,
loadActiveAccountId,
loadDraftInstanceUrl,
saveAccounts,
saveActiveAccountId,
saveDraftInstanceUrl,
type SavedAccount,
} from "@/lib/accounts";
import { migrateAuthStorage } from "@/lib/auth-storage";
import { DEFAULT_API_URL } from "@/lib/config";
import { loadStoredInstanceUrl, saveStoredInstanceUrl } from "@/lib/instance-url";
import {
clearTimeClockPrefsForAccount,
getLastTimeClockClientId,
setLastTimeClockClientId,
} from "@/lib/time-clock-prefs";
const LEGACY_OFFICIAL_HOSTS = ["beenvoice.soconnor.dev"];
export function isLegacyOfficialUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed) return false;
try {
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
return LEGACY_OFFICIAL_HOSTS.includes(new URL(withProtocol).host);
} catch {
const host = trimmed.replace(/^https?:\/\//, "").replace(/\/$/, "").split("/")[0] ?? "";
return LEGACY_OFFICIAL_HOSTS.includes(host);
}
}
export function migrateOfficialUrl(url: string): string {
return isLegacyOfficialUrl(url) ? DEFAULT_API_URL : url;
}
export async function migrateStoredOfficialUrls(): Promise<{
accounts: SavedAccount[];
activeAccountId: string | null;
draftUrl: string | null;
}> {
const [accounts, activeId, draftUrl, storedInstanceUrl] = await Promise.all([
loadAccounts(),
loadActiveAccountId(),
loadDraftInstanceUrl(),
loadStoredInstanceUrl(),
]);
let nextActiveId = activeId;
const migratedAccounts: SavedAccount[] = [];
for (const account of accounts) {
if (!isLegacyOfficialUrl(account.instanceUrl)) {
migratedAccounts.push(account);
continue;
}
const newUrl = DEFAULT_API_URL;
const newId = buildAccountId(newUrl, account.userId);
const oldPrefix = authStoragePrefix(account.id);
const newPrefix = authStoragePrefix(newId);
if (oldPrefix !== newPrefix) {
await migrateAuthStorage(oldPrefix, newPrefix);
}
const lastClientId = await getLastTimeClockClientId(account.id);
if (lastClientId && account.id !== newId) {
await setLastTimeClockClientId(newId, lastClientId);
await clearTimeClockPrefsForAccount(account.id);
}
migratedAccounts.push({
...account,
id: newId,
instanceUrl: newUrl,
});
if (nextActiveId === account.id) {
nextActiveId = newId;
}
}
const newDraft = draftUrl && isLegacyOfficialUrl(draftUrl) ? DEFAULT_API_URL : draftUrl;
const accountsChanged = migratedAccounts.some(
(account, index) =>
account.id !== accounts[index]?.id || account.instanceUrl !== accounts[index]?.instanceUrl,
);
const activeChanged = nextActiveId !== activeId;
const draftChanged = newDraft !== draftUrl;
const instanceChanged =
storedInstanceUrl != null &&
isLegacyOfficialUrl(storedInstanceUrl) &&
storedInstanceUrl !== DEFAULT_API_URL;
if (accountsChanged) await saveAccounts(migratedAccounts);
if (activeChanged) await saveActiveAccountId(nextActiveId);
if (draftChanged) await saveDraftInstanceUrl(newDraft);
if (instanceChanged) await saveStoredInstanceUrl(DEFAULT_API_URL);
return {
accounts: migratedAccounts,
activeAccountId: nextActiveId,
draftUrl: newDraft,
};
}
+1
View File
@@ -60,6 +60,7 @@ export function parseShortcutUrl(url: string | null | undefined): ParsedShortcut
export const SHORTCUT_URLS = {
timer: "beenvoice://timer",
openTimer: "beenvoice://timer",
clockIn: "beenvoice://shortcuts/clock-in",
clockOut: "beenvoice://shortcuts/clock-out",
} as const;
+2 -1
View File
@@ -91,7 +91,8 @@ export async function syncTimeClockLiveActivity(
return;
}
factory.start(props, "beenvoice://timer");
const instance = factory.start(props, "beenvoice://timer");
await instance.update(props);
} catch (error) {
if (__DEV__) {
console.warn("[LiveActivity] sync failed:", error);
+15
View File
@@ -6,11 +6,26 @@ export type ClockOutOutcome =
export const DEFAULT_CLOCK_DESCRIPTION = "Clock In";
/** Stored on entries clocked in before empty descriptions were allowed. */
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
export function resolveClockDescription(description: string | null | undefined): string {
const trimmed = description?.trim();
return trimmed || DEFAULT_CLOCK_DESCRIPTION;
}
export function formatRunningTimerLabel(description?: string | null): string {
const trimmed = description?.trim() ?? "";
if (
!trimmed ||
trimmed === DEFAULT_CLOCK_DESCRIPTION ||
trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION
) {
return "Clocked in";
}
return trimmed;
}
export function formatElapsedSeconds(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);