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:
+9
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user