feat: add business brand assets and health checks
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user