feat: add business brand assets and health checks
This commit is contained in:
@@ -1,12 +1,6 @@
|
||||
import { router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { AppBackground } from "@/components/AppBackground";
|
||||
import { FilterChip } from "@/components/FilterChip";
|
||||
@@ -24,6 +18,10 @@ import { formatCurrency } from "@/lib/format";
|
||||
import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { api } from "@/lib/trpc";
|
||||
import {
|
||||
BusinessBrandImage,
|
||||
hasMobileBusinessBrandAsset,
|
||||
} from "@/components/businesses/BusinessBrandImage";
|
||||
|
||||
type EntityTab = "clients" | "businesses";
|
||||
|
||||
@@ -48,7 +46,8 @@ export default function EntitiesScreen() {
|
||||
|
||||
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
||||
const isLoading =
|
||||
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
|
||||
clientsQuery.isLoading ||
|
||||
(tab === "businesses" && businessesQuery.isLoading);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen message="Loading…" />;
|
||||
@@ -71,21 +70,27 @@ export default function EntitiesScreen() {
|
||||
const businesses = businessesQuery.data ?? [];
|
||||
|
||||
function refresh() {
|
||||
return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch();
|
||||
return tab === "clients"
|
||||
? clientsQuery.refetch()
|
||||
: businessesQuery.refetch();
|
||||
}
|
||||
|
||||
function confirmDelete(id: string, name: string) {
|
||||
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
if (tab === "clients") deleteClient.mutate({ id });
|
||||
else deleteBusiness.mutate({ id });
|
||||
Alert.alert(
|
||||
`Delete ${tab === "clients" ? "client" : "business"}?`,
|
||||
`Remove ${name}?`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
if (tab === "clients") deleteClient.mutate({ id });
|
||||
else deleteBusiness.mutate({ id });
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -99,10 +104,7 @@ export default function EntitiesScreen() {
|
||||
/>
|
||||
}
|
||||
refreshControl={
|
||||
<PullToRefresh
|
||||
onRefresh={refresh}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
<PullToRefresh onRefresh={refresh} tintColor={colors.primary} />
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
@@ -141,7 +143,10 @@ export default function EntitiesScreen() {
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
|
||||
onPress: () =>
|
||||
router.push(
|
||||
`/(app)/entities/clients/edit/${client.id}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
@@ -152,7 +157,9 @@ export default function EntitiesScreen() {
|
||||
onPress: () => confirmDelete(client.id, client.name),
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
|
||||
onPress={() =>
|
||||
router.push(`/(app)/entities/clients/${client.id}`)
|
||||
}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
@@ -162,7 +169,10 @@ export default function EntitiesScreen() {
|
||||
) : null}
|
||||
{client.defaultHourlyRate != null ? (
|
||||
<Text style={styles.meta}>
|
||||
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
||||
{formatCurrency(
|
||||
client.defaultHourlyRate,
|
||||
client.currency ?? "USD",
|
||||
)}
|
||||
/hr
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -190,7 +200,10 @@ export default function EntitiesScreen() {
|
||||
icon: "create-outline",
|
||||
color: "#fff",
|
||||
backgroundColor: colors.primary,
|
||||
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
|
||||
onPress: () =>
|
||||
router.push(
|
||||
`/(app)/entities/businesses/edit/${business.id}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
@@ -201,20 +214,35 @@ export default function EntitiesScreen() {
|
||||
onPress: () => confirmDelete(business.id, business.name),
|
||||
},
|
||||
]}
|
||||
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
|
||||
onPress={() =>
|
||||
router.push(`/(app)/entities/businesses/${business.id}`)
|
||||
}
|
||||
>
|
||||
<GlassSurface style={styles.card}>
|
||||
<View style={styles.cardInner}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? (
|
||||
<Text style={styles.badge}>Default</Text>
|
||||
<View style={styles.businessRow}>
|
||||
{hasMobileBusinessBrandAsset(business) ? (
|
||||
<BusinessBrandImage
|
||||
business={business}
|
||||
kind="icon"
|
||||
style={styles.businessIcon}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.businessCopy}>
|
||||
<View style={styles.nameRow}>
|
||||
<Text style={styles.name}>{business.name}</Text>
|
||||
{business.isDefault ? (
|
||||
<Text style={styles.badge}>Default</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{business.nickname ? (
|
||||
<Text style={styles.meta}>{business.nickname}</Text>
|
||||
) : null}
|
||||
{business.email ? (
|
||||
<Text style={styles.meta}>{business.email}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{business.nickname ? (
|
||||
<Text style={styles.meta}>{business.nickname}</Text>
|
||||
) : null}
|
||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
||||
</View>
|
||||
</GlassSurface>
|
||||
</SwipeableRow>
|
||||
@@ -257,6 +285,21 @@ const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
businessRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.md,
|
||||
},
|
||||
businessCopy: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: 4,
|
||||
},
|
||||
businessIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
|
||||
@@ -8,6 +8,11 @@ import { Logo } from "@/components/Logo";
|
||||
import { fonts, radii, spacing } from "@/constants/theme";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
|
||||
import {
|
||||
BusinessBrandImage,
|
||||
hasMobileBusinessBrandAsset,
|
||||
} from "@/components/businesses/BusinessBrandImage";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
type TopChromeProps = {
|
||||
showMoreBack?: boolean;
|
||||
@@ -16,6 +21,7 @@ type TopChromeProps = {
|
||||
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
|
||||
export function TopChrome({ showMoreBack = false }: TopChromeProps) {
|
||||
const { colors, isDark } = useAppTheme();
|
||||
const defaultBusiness = api.businesses.getDefault.useQuery();
|
||||
|
||||
function handleBack() {
|
||||
if (router.canGoBack()) {
|
||||
@@ -34,13 +40,25 @@ export function TopChrome({ showMoreBack = false }: TopChromeProps) {
|
||||
onPress={handleBack}
|
||||
style={({ pressed }) => [
|
||||
styles.backButton,
|
||||
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
|
||||
{
|
||||
borderColor: colors.borderGlass,
|
||||
backgroundColor: colors.cardGlass,
|
||||
},
|
||||
pressed && styles.pressed,
|
||||
]}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={18} color={colors.foreground} />
|
||||
<Text style={[styles.backLabel, { color: colors.foreground }]}>More</Text>
|
||||
<Text style={[styles.backLabel, { color: colors.foreground }]}>
|
||||
More
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : defaultBusiness.data &&
|
||||
hasMobileBusinessBrandAsset(defaultBusiness.data) ? (
|
||||
<BusinessBrandImage
|
||||
business={defaultBusiness.data}
|
||||
kind="wordmark"
|
||||
style={styles.businessWordmark}
|
||||
/>
|
||||
) : (
|
||||
<Logo size="xs" onDark={isDark} />
|
||||
)}
|
||||
@@ -66,6 +84,10 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.pill,
|
||||
},
|
||||
businessWordmark: {
|
||||
width: 132,
|
||||
height: 32,
|
||||
},
|
||||
backLabel: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 13,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Image, type ImageStyle, type StyleProp } from "react-native";
|
||||
|
||||
import { useAccounts } from "@/contexts/AccountsContext";
|
||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||
import { hasBusinessBrandAsset } from "@beenvoice/domain/brand-assets";
|
||||
|
||||
type BrandAssetKind = "logo" | "wordmark" | "icon";
|
||||
type BrandAssetTheme = "light" | "dark";
|
||||
|
||||
type BusinessBrandImageProps = {
|
||||
business: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoDarkStorageKey?: string | null;
|
||||
wordmarkLightStorageKey?: string | null;
|
||||
wordmarkDarkStorageKey?: string | null;
|
||||
iconLightStorageKey?: string | null;
|
||||
iconDarkStorageKey?: string | null;
|
||||
};
|
||||
kind?: BrandAssetKind;
|
||||
theme?: BrandAssetTheme;
|
||||
style?: StyleProp<ImageStyle>;
|
||||
};
|
||||
|
||||
export function hasMobileBusinessBrandAsset(
|
||||
business: BusinessBrandImageProps["business"],
|
||||
) {
|
||||
return hasBusinessBrandAsset(business);
|
||||
}
|
||||
|
||||
export function BusinessBrandImage({
|
||||
business,
|
||||
kind = "icon",
|
||||
theme: themeOverride,
|
||||
style,
|
||||
}: BusinessBrandImageProps) {
|
||||
const { apiUrl } = useAccounts();
|
||||
const { isDark } = useAppTheme();
|
||||
if (!hasMobileBusinessBrandAsset(business)) return null;
|
||||
const theme = themeOverride ?? (isDark ? "dark" : "light");
|
||||
const base = apiUrl.replace(/\/$/, "");
|
||||
return (
|
||||
<Image
|
||||
source={{
|
||||
uri: `${base}/api/business-logo/${business.id}?kind=${kind}&theme=${theme}&format=png`,
|
||||
}}
|
||||
accessibilityLabel={`${business.name ?? "Business"} ${kind}`}
|
||||
resizeMode="contain"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
@@ -19,6 +20,12 @@ import type { ThemeColors } from "@/lib/theme-palette";
|
||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||
import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
|
||||
import { api } from "@/lib/trpc";
|
||||
import { BusinessBrandImage } from "@/components/businesses/BusinessBrandImage";
|
||||
import {
|
||||
getBrandAssetFieldNames,
|
||||
type BrandAssetKind,
|
||||
type BrandAssetTheme,
|
||||
} from "@beenvoice/domain/brand-assets";
|
||||
|
||||
type BusinessFormValues = {
|
||||
name: string;
|
||||
@@ -33,6 +40,7 @@ type BusinessFormValues = {
|
||||
country: string;
|
||||
website: string;
|
||||
taxId: string;
|
||||
hideNameWithLogo: boolean;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
@@ -49,9 +57,24 @@ const emptyValues: BusinessFormValues = {
|
||||
country: "United States",
|
||||
website: "",
|
||||
taxId: "",
|
||||
hideNameWithLogo: false,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
const MAX_BRAND_ASSET_BYTES = 5 * 1024 * 1024;
|
||||
const BRAND_ASSET_SLOTS: Array<{
|
||||
kind: BrandAssetKind;
|
||||
theme: BrandAssetTheme;
|
||||
label: string;
|
||||
}> = [
|
||||
{ kind: "logo", theme: "light", label: "Logo · light" },
|
||||
{ kind: "logo", theme: "dark", label: "Logo · dark" },
|
||||
{ kind: "wordmark", theme: "light", label: "Wordmark · light" },
|
||||
{ kind: "wordmark", theme: "dark", label: "Wordmark · dark" },
|
||||
{ kind: "icon", theme: "light", label: "Icon · light" },
|
||||
{ kind: "icon", theme: "dark", label: "Icon · dark" },
|
||||
];
|
||||
|
||||
type BusinessFormProps = {
|
||||
mode: "create" | "edit";
|
||||
businessId?: string;
|
||||
@@ -78,6 +101,7 @@ export function BusinessForm({
|
||||
|
||||
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
|
||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||
|
||||
const switchProps = {
|
||||
@@ -102,6 +126,7 @@ export function BusinessForm({
|
||||
country: business.country ?? "United States",
|
||||
website: business.website ?? "",
|
||||
taxId: business.taxId ?? "",
|
||||
hideNameWithLogo: business.hideNameWithLogo ?? false,
|
||||
isDefault: business.isDefault ?? false,
|
||||
});
|
||||
}, [businessQuery.data]);
|
||||
@@ -117,7 +142,8 @@ export function BusinessForm({
|
||||
const updateBusiness = api.businesses.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.businesses.getAll.invalidate();
|
||||
if (businessId) void utils.businesses.getById.invalidate({ id: businessId });
|
||||
if (businessId)
|
||||
void utils.businesses.getById.invalidate({ id: businessId });
|
||||
onSaved();
|
||||
},
|
||||
onError: (err) => setFieldError(err.message),
|
||||
@@ -130,8 +156,68 @@ export function BusinessForm({
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not delete business", err.message),
|
||||
});
|
||||
const uploadLogo = api.businesses.uploadLogo.useMutation({
|
||||
onSuccess: () => {
|
||||
if (businessId)
|
||||
void utils.businesses.getById.invalidate({ id: businessId });
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not upload brand asset", err.message),
|
||||
onSettled: () => setUploadingAsset(null),
|
||||
});
|
||||
const removeLogo = api.businesses.removeLogo.useMutation({
|
||||
onSuccess: () => {
|
||||
if (businessId)
|
||||
void utils.businesses.getById.invalidate({ id: businessId });
|
||||
},
|
||||
onError: (err) => Alert.alert("Could not remove brand asset", err.message),
|
||||
});
|
||||
|
||||
function patch<K extends keyof BusinessFormValues>(field: K, value: BusinessFormValues[K]) {
|
||||
async function pickBrandAsset(kind: BrandAssetKind, theme: BrandAssetTheme) {
|
||||
if (!businessId) return;
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert(
|
||||
"Photos access needed",
|
||||
"Allow photo access to upload branding.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ["images"],
|
||||
quality: 1,
|
||||
base64: true,
|
||||
});
|
||||
if (result.canceled) return;
|
||||
const asset = result.assets[0];
|
||||
if (!asset?.base64) return;
|
||||
const mimeType = asset.mimeType ?? "image/jpeg";
|
||||
if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
|
||||
Alert.alert(
|
||||
"Unsupported image",
|
||||
"Mobile supports PNG, JPEG, and WebP. Upload SVG assets from the web app.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (asset.fileSize && asset.fileSize > MAX_BRAND_ASSET_BYTES) {
|
||||
Alert.alert("Image too large", "Brand assets must be 5MB or less.");
|
||||
return;
|
||||
}
|
||||
const key = `${kind}-${theme}`;
|
||||
setUploadingAsset(key);
|
||||
uploadLogo.mutate({
|
||||
id: businessId,
|
||||
kind,
|
||||
theme,
|
||||
filename: asset.fileName ?? `${key}.jpg`,
|
||||
mimeType,
|
||||
data: asset.base64,
|
||||
});
|
||||
}
|
||||
|
||||
function patch<K extends keyof BusinessFormValues>(
|
||||
field: K,
|
||||
value: BusinessFormValues[K],
|
||||
) {
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
setFieldError(null);
|
||||
}
|
||||
@@ -150,6 +236,7 @@ export function BusinessForm({
|
||||
country: values.country.trim() || "United States",
|
||||
website: values.website.trim(),
|
||||
taxId: values.taxId.trim(),
|
||||
hideNameWithLogo: values.hideNameWithLogo,
|
||||
isDefault: values.isDefault,
|
||||
};
|
||||
}
|
||||
@@ -186,7 +273,9 @@ export function BusinessForm({
|
||||
}
|
||||
|
||||
const saving = createBusiness.isPending || updateBusiness.isPending;
|
||||
const nameError = values.name.trim() ? undefined : "Business name is required";
|
||||
const nameError = values.name.trim()
|
||||
? undefined
|
||||
: "Business name is required";
|
||||
const canSave = isRequiredString(values.name);
|
||||
|
||||
return (
|
||||
@@ -195,8 +284,13 @@ export function BusinessForm({
|
||||
style={styles.flex}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
||||
contentContainerStyle={[
|
||||
styles.container,
|
||||
{ paddingBottom: scrollPadding },
|
||||
]}
|
||||
contentInsetAdjustmentBehavior={
|
||||
Platform.OS === "ios" ? "automatic" : undefined
|
||||
}
|
||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
@@ -247,7 +341,9 @@ export function BusinessForm({
|
||||
<Text style={[styles.switchLabel, { color: colors.foreground }]}>
|
||||
Default business
|
||||
</Text>
|
||||
<Text style={[styles.switchHint, { color: colors.mutedForeground }]}>
|
||||
<Text
|
||||
style={[styles.switchHint, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Used for new invoices when none is selected
|
||||
</Text>
|
||||
</View>
|
||||
@@ -259,6 +355,108 @@ export function BusinessForm({
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{mode === "edit" && businessQuery.data ? (
|
||||
<Card title="Brand assets">
|
||||
<Text style={[styles.brandHint, { color: colors.mutedForeground }]}>
|
||||
Upload combined logos, wordmarks, and compact icons for light and
|
||||
dark backgrounds. Missing variants fall back automatically.
|
||||
</Text>
|
||||
{BRAND_ASSET_SLOTS.map((slot) => {
|
||||
const [storageField] = getBrandAssetFieldNames(
|
||||
slot.kind,
|
||||
slot.theme,
|
||||
);
|
||||
const hasAsset = Boolean(businessQuery.data[storageField]);
|
||||
const key = `${slot.kind}-${slot.theme}`;
|
||||
return (
|
||||
<View key={key} style={styles.brandSlot}>
|
||||
<View
|
||||
style={[
|
||||
styles.brandPreview,
|
||||
{
|
||||
backgroundColor:
|
||||
slot.theme === "dark" ? "#09090b" : "#ffffff",
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{hasAsset ? (
|
||||
<BusinessBrandImage
|
||||
business={businessQuery.data}
|
||||
kind={slot.kind}
|
||||
theme={slot.theme}
|
||||
style={styles.brandImage}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
color:
|
||||
slot.theme === "dark"
|
||||
? "rgba(255,255,255,0.45)"
|
||||
: "rgba(0,0,0,0.35)",
|
||||
}}
|
||||
>
|
||||
No asset
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.brandLabel, { color: colors.foreground }]}
|
||||
>
|
||||
{slot.label}
|
||||
</Text>
|
||||
<View style={styles.brandActions}>
|
||||
<Button
|
||||
title={hasAsset ? "Replace" : "Upload"}
|
||||
variant="secondary"
|
||||
leftIcon="cloud-upload-outline"
|
||||
loading={uploadingAsset === key}
|
||||
disabled={Boolean(uploadingAsset)}
|
||||
style={styles.brandAction}
|
||||
onPress={() => void pickBrandAsset(slot.kind, slot.theme)}
|
||||
/>
|
||||
{hasAsset ? (
|
||||
<Button
|
||||
title="Remove"
|
||||
variant="ghost"
|
||||
leftIcon="trash-outline"
|
||||
loading={removeLogo.isPending}
|
||||
style={styles.brandAction}
|
||||
onPress={() =>
|
||||
removeLogo.mutate({
|
||||
id: businessId!,
|
||||
kind: slot.kind,
|
||||
theme: slot.theme,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View style={styles.switchRow}>
|
||||
<View style={styles.switchCopy}>
|
||||
<Text
|
||||
style={[styles.switchLabel, { color: colors.foreground }]}
|
||||
>
|
||||
Hide business name on invoices
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.switchHint, { color: colors.mutedForeground }]}
|
||||
>
|
||||
Useful when the combined logo already includes the name
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={values.hideNameWithLogo}
|
||||
onValueChange={(value) => patch("hideNameWithLogo", value)}
|
||||
{...switchProps}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Address">
|
||||
<Input
|
||||
label="Address line 1"
|
||||
@@ -270,8 +468,16 @@ export function BusinessForm({
|
||||
value={values.addressLine2}
|
||||
onChangeText={(v) => patch("addressLine2", v)}
|
||||
/>
|
||||
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
|
||||
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
|
||||
<Input
|
||||
label="City"
|
||||
value={values.city}
|
||||
onChangeText={(v) => patch("city", v)}
|
||||
/>
|
||||
<Input
|
||||
label="State"
|
||||
value={values.state}
|
||||
onChangeText={(v) => patch("state", v)}
|
||||
/>
|
||||
<Input
|
||||
label="Postal code"
|
||||
value={values.postalCode}
|
||||
@@ -284,7 +490,11 @@ export function BusinessForm({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
|
||||
{fieldError ? (
|
||||
<Text selectable style={styles.error}>
|
||||
{fieldError}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
@@ -337,6 +547,38 @@ const createBusinessFormStyles = (colors: ThemeColors, _isDark: boolean) =>
|
||||
actions: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
brandHint: {
|
||||
fontFamily: fonts.body,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
brandSlot: {
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
brandPreview: {
|
||||
height: 92,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: spacing.md,
|
||||
},
|
||||
brandImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
brandLabel: {
|
||||
fontFamily: fonts.bodySemiBold,
|
||||
fontSize: 14,
|
||||
},
|
||||
brandActions: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
brandAction: {
|
||||
flex: 1,
|
||||
},
|
||||
error: {
|
||||
color: colors.destructive,
|
||||
fontFamily: fonts.body,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
EXPENSE_CATEGORIES as domainExpenseCategories,
|
||||
addCalendarDays,
|
||||
calendarDateFromInstant,
|
||||
formatElapsedSeconds as formatDomainElapsedSeconds,
|
||||
getEffectiveInvoiceStatus,
|
||||
} from "@beenvoice/domain";
|
||||
@@ -14,9 +16,7 @@ import { generateInvoiceNumber as generateMobileInvoiceNumber } from "../lib/inv
|
||||
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
|
||||
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
|
||||
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
|
||||
import {
|
||||
normalizeOptionalId,
|
||||
} from "../../web/src/lib/time-clock";
|
||||
import { normalizeOptionalId } from "../../web/src/lib/time-clock";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -52,15 +52,18 @@ describe("invoice parity", () => {
|
||||
test("web and mobile use the device-local date in invoice numbers", () => {
|
||||
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
|
||||
|
||||
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
|
||||
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
|
||||
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith(
|
||||
"INV-20260816-",
|
||||
);
|
||||
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith(
|
||||
"INV-20260816-",
|
||||
);
|
||||
});
|
||||
|
||||
test("web and mobile agree on draft, paid, sent, and overdue states", () => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const timeZone = "America/New_York";
|
||||
const today = calendarDateFromInstant(new Date(), timeZone);
|
||||
const yesterday = addCalendarDays(today, -1);
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
@@ -82,11 +85,15 @@ describe("invoice parity", () => {
|
||||
];
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
expect(getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate)).toBe(
|
||||
fixture.expected,
|
||||
);
|
||||
expect(
|
||||
getInvoiceStatus({ status: fixture.stored, dueDate: fixture.dueDate }),
|
||||
getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate, timeZone),
|
||||
).toBe(fixture.expected);
|
||||
expect(
|
||||
getInvoiceStatus({
|
||||
status: fixture.stored,
|
||||
dueDate: fixture.dueDate,
|
||||
createdBy: { timeZone },
|
||||
}),
|
||||
).toBe(fixture.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user