import { useEffect, useState } from "react"; import * as ImagePicker from "expo-image-picker"; import { Alert, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Switch, Text, View, } from "react-native"; import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; import { Input } from "@/components/ui/Input"; import { fonts, spacing } from "@/constants/theme"; import { useAppTheme } from "@/contexts/ThemeContext"; 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; nickname: string; email: string; phone: string; addressLine1: string; addressLine2: string; city: string; state: string; postalCode: string; country: string; website: string; taxId: string; hideNameWithLogo: boolean; isDefault: boolean; }; const emptyValues: BusinessFormValues = { name: "", nickname: "", email: "", phone: "", addressLine1: "", addressLine2: "", city: "", state: "", postalCode: "", 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; scrollPadding: number; onSaved: () => void; onDeleted?: () => void; }; export function BusinessForm({ mode, businessId, scrollPadding, onSaved, onDeleted, }: BusinessFormProps) { const { colors } = useAppTheme(); const styles = useThemedStyles(createBusinessFormStyles); const utils = api.useUtils(); const businessQuery = api.businesses.getById.useQuery( { id: businessId ?? "" }, { enabled: mode === "edit" && Boolean(businessId) }, ); const [values, setValues] = useState(emptyValues); const [fieldError, setFieldError] = useState(null); const [uploadingAsset, setUploadingAsset] = useState(null); const { touch, visible, markSubmitted } = useFieldVisibility(); const switchProps = { trackColor: { false: colors.switchTrackOff, true: colors.switchTrackOn }, thumbColor: Platform.OS === "android" ? colors.switchThumb : undefined, ios_backgroundColor: colors.switchIosBackground, }; useEffect(() => { const business = businessQuery.data; if (!business) return; setValues({ name: business.name, nickname: business.nickname ?? "", email: business.email ?? "", phone: business.phone ?? "", addressLine1: business.addressLine1 ?? "", addressLine2: business.addressLine2 ?? "", city: business.city ?? "", state: business.state ?? "", postalCode: business.postalCode ?? "", country: business.country ?? "United States", website: business.website ?? "", taxId: business.taxId ?? "", hideNameWithLogo: business.hideNameWithLogo ?? false, isDefault: business.isDefault ?? false, }); }, [businessQuery.data]); const createBusiness = api.businesses.create.useMutation({ onSuccess: () => { void utils.businesses.getAll.invalidate(); onSaved(); }, onError: (err) => setFieldError(err.message), }); const updateBusiness = api.businesses.update.useMutation({ onSuccess: () => { void utils.businesses.getAll.invalidate(); if (businessId) void utils.businesses.getById.invalidate({ id: businessId }); onSaved(); }, onError: (err) => setFieldError(err.message), }); const deleteBusiness = api.businesses.delete.useMutation({ onSuccess: () => { void utils.businesses.getAll.invalidate(); onDeleted?.(); }, 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), }); 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( field: K, value: BusinessFormValues[K], ) { setValues((prev) => ({ ...prev, [field]: value })); setFieldError(null); } function buildPayload() { return { name: values.name.trim(), nickname: values.nickname.trim(), email: values.email.trim(), phone: values.phone.trim(), addressLine1: values.addressLine1.trim(), addressLine2: values.addressLine2.trim(), city: values.city.trim(), state: values.state.trim(), postalCode: values.postalCode.trim(), country: values.country.trim() || "United States", website: values.website.trim(), taxId: values.taxId.trim(), hideNameWithLogo: values.hideNameWithLogo, isDefault: values.isDefault, }; } function handleSave() { markSubmitted(); if (!canSave) return; const payload = buildPayload(); if (mode === "create") { createBusiness.mutate(payload); return; } if (!businessId) return; updateBusiness.mutate({ id: businessId, ...payload }); } function confirmDelete() { if (!businessId) return; Alert.alert( "Delete business", "This cannot be undone. Businesses with invoices cannot be deleted.", [ { text: "Cancel", style: "cancel" }, { text: "Delete", style: "destructive", onPress: () => deleteBusiness.mutate({ id: businessId }), }, ], ); } const saving = createBusiness.isPending || updateBusiness.isPending; const nameError = values.name.trim() ? undefined : "Business name is required"; const canSave = isRequiredString(values.name); return ( patch("name", v)} onBlur={() => touch("name")} required error={visible("name") ? nameError : undefined} /> patch("nickname", v)} placeholder="Optional short name" /> patch("email", v)} keyboardType="email-address" autoCapitalize="none" /> patch("phone", v)} keyboardType="phone-pad" /> patch("website", v)} autoCapitalize="none" keyboardType="url" placeholder="https://" /> patch("taxId", v)} placeholder="Optional" /> Default business Used for new invoices when none is selected patch("isDefault", v)} {...switchProps} /> {mode === "edit" && businessQuery.data ? ( Upload combined logos, wordmarks, and compact icons for light and dark backgrounds. Missing variants fall back automatically. {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 ( {hasAsset ? ( ) : ( No asset )} {slot.label}