"use client"; import { ArrowLeft, Building, Eye, EyeOff, FileText, Globe, ImageIcon, Info, Key, Loader2, Mail, Save, Star, Trash2, Upload, User, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { AddressForm } from "~/components/forms/address-form"; import { FloatingActionBar } from "~/components/layout/floating-action-bar"; import { DashboardPageHeader } from "~/components/layout/page-header"; import { DashboardPage, dashboardGapClass } from "~/components/layout/dashboard-page"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { Alert, AlertDescription } from "~/components/ui/alert"; import { Badge } from "~/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Skeleton } from "~/components/ui/skeleton"; import { Switch } from "~/components/ui/switch"; import { formatPhoneNumber, formatTaxId, formatWebsiteUrl, isValidEmail, PLACEHOLDERS, VALIDATION_MESSAGES, } from "~/lib/form-constants"; import { api } from "~/trpc/react"; interface BusinessFormProps { businessId?: string; mode: "create" | "edit"; } interface FormData { 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; resendApiKey: string; resendDomain: string; emailFromName: string; } interface FormErrors { name?: string; nickname?: string; email?: string; phone?: string; addressLine1?: string; city?: string; state?: string; postalCode?: string; country?: string; website?: string; taxId?: string; resendApiKey?: string; resendDomain?: string; emailFromName?: string; } const initialFormData: FormData = { name: "", nickname: "", email: "", phone: "", addressLine1: "", addressLine2: "", city: "", state: "", postalCode: "", country: "United States", website: "", taxId: "", hideNameWithLogo: false, isDefault: false, resendApiKey: "", resendDomain: "", emailFromName: "", }; const MAX_LOGO_BYTES = 5 * 1024 * 1024; const ACCEPTED_LOGO_TYPES = new Set([ "image/png", "image/jpeg", "image/webp", "image/svg+xml", ]); export function BusinessForm({ businessId, mode }: BusinessFormProps) { const router = useRouter(); const utils = api.useUtils(); const [formData, setFormData] = useState(initialFormData); const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); const [showApiKey, setShowApiKey] = useState(false); const [isDirty, setIsDirty] = useState(false); const [initialized, setInitialized] = useState(false); const [isUploadingLogo, setIsUploadingLogo] = useState(false); // Fetch business data if editing const { data: business, isLoading: isLoadingBusiness } = api.businesses.getById.useQuery( { id: businessId! }, { enabled: mode === "edit" && !!businessId, refetchOnWindowFocus: false, }, ); // Fetch email configuration if editing const { data: emailConfig, isLoading: isLoadingEmailConfig } = api.businesses.getEmailConfig.useQuery( { id: businessId! }, { enabled: mode === "edit" && !!businessId, refetchOnWindowFocus: false, }, ); // Update email configuration mutation const updateEmailConfig = api.businesses.updateEmailConfig.useMutation({ onError: (error) => { toast.error(`Failed to update email configuration: ${error.message}`); }, }); const createBusiness = api.businesses.create.useMutation({ onError: (error) => { toast.error(error.message || "Failed to create business"); }, }); const updateBusiness = api.businesses.update.useMutation({ onError: (error) => { toast.error(error.message || "Failed to update business"); }, }); const uploadLogo = api.businesses.uploadLogo.useMutation({ onSuccess: async () => { await utils.businesses.getById.invalidate({ id: businessId }); toast.success("Logo updated"); }, onError: (error) => { toast.error(error.message || "Failed to upload logo"); }, onSettled: () => setIsUploadingLogo(false), }); const removeLogo = api.businesses.removeLogo.useMutation({ onSuccess: async () => { await utils.businesses.getById.invalidate({ id: businessId }); toast.success("Logo removed"); }, onError: (error) => { toast.error(error.message || "Failed to remove logo"); }, }); const handleLogoFileSelected = async ( e: React.ChangeEvent, ) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file || !businessId) return; if (!ACCEPTED_LOGO_TYPES.has(file.type)) { toast.error("Logo must be a PNG, JPEG, WebP, or SVG image"); return; } if (file.size > MAX_LOGO_BYTES) { toast.error("Logo must be 5MB or less"); return; } setIsUploadingLogo(true); const data = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result; resolve(typeof result === "string" ? (result.split(",")[1] ?? "") : ""); }; reader.onerror = reject; reader.readAsDataURL(file); }); uploadLogo.mutate({ id: businessId, filename: file.name, mimeType: file.type, data, }); }; useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset form when navigating to a different business. setInitialized(false); setIsDirty(false); setFormData(initialFormData); }, [businessId]); // Load business data once when editing (avoid overwriting unsaved changes on refetch) useEffect(() => { if ( business && mode === "edit" && !initialized && !isLoadingEmailConfig ) { // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form. setFormData({ 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, resendApiKey: "", // Never pre-fill API key for security resendDomain: emailConfig?.resendDomain ?? "", emailFromName: emailConfig?.emailFromName ?? "", }); setInitialized(true); } }, [business, emailConfig, mode, initialized, isLoadingEmailConfig]); const handleInputChange = (field: string, value: string | boolean) => { setFormData((prev) => ({ ...prev, [field]: value })); setIsDirty(true); // Clear error for this field when user starts typing if (errors[field as keyof FormErrors]) { setErrors((prev) => ({ ...prev, [field]: undefined })); } }; const handlePhoneChange = (value: string) => { const formatted = formatPhoneNumber(value); handleInputChange("phone", formatted); }; const handleTaxIdChange = (value: string) => { const formatted = formatTaxId(value, "EIN"); handleInputChange("taxId", formatted); }; const validateForm = (): boolean => { const newErrors: FormErrors = {}; // Required fields if (!formData.name.trim()) { newErrors.name = VALIDATION_MESSAGES.required; } // Nickname validation (optional, max 255 chars) if (formData.nickname && formData.nickname.trim().length > 255) { newErrors.nickname = "Nickname must be 255 characters or less"; } // Email validation if (formData.email.trim() && !isValidEmail(formData.email.trim())) { newErrors.email = VALIDATION_MESSAGES.email; } // Phone validation (basic check for US format) if (formData.phone.trim()) { const phoneDigits = formData.phone.replace(/\D/g, ""); if (phoneDigits.length > 0 && phoneDigits.length < 10) { newErrors.phone = VALIDATION_MESSAGES.phone; } } // Address validation if any address field is filled (excluding country as it has a default) const hasAddressData = !!( formData.addressLine1.trim() || formData.city.trim() || formData.state.trim() || formData.postalCode.trim() ); // Also check if country was explicitly changed from default const hasNonDefaultCountry = formData.country.trim() && formData.country.trim() !== "United States"; const hasAnyAddressInput = hasAddressData || hasNonDefaultCountry; // Only validate address if user has actually entered address data if (hasAnyAddressInput) { if (!formData.addressLine1.trim()) newErrors.addressLine1 = VALIDATION_MESSAGES.required; if (!formData.city.trim()) newErrors.city = VALIDATION_MESSAGES.required; if (!formData.country.trim()) newErrors.country = VALIDATION_MESSAGES.required; // Only require US-specific fields if country is United States AND we have actual address data if (formData.country.trim() === "United States" && hasAddressData) { if (!formData.state.trim()) newErrors.state = VALIDATION_MESSAGES.required; if (!formData.postalCode.trim()) newErrors.postalCode = VALIDATION_MESSAGES.required; } } // Email configuration validation // API Key validation if ( formData.resendApiKey.trim() && !formData.resendApiKey.trim().startsWith("re_") ) { newErrors.resendApiKey = "Resend API key should start with 're_'"; } // Domain validation if (formData.resendDomain.trim()) { const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.([a-zA-Z]{2,})+$/; if (!domainRegex.test(formData.resendDomain.trim())) { newErrors.resendDomain = "Please enter a valid domain (e.g., yourdomain.com)"; } } // If API key is provided, domain must also be provided if (formData.resendApiKey.trim() && !formData.resendDomain.trim()) { newErrors.resendDomain = "Domain is required when API key is provided"; } // If domain is provided, API key must also be provided (unless there's already one on the server) // In edit mode, if domain comes from server and API key field is empty, don't require new API key const userEnteredDomain = formData.resendDomain.trim() !== (emailConfig?.resendDomain ?? ""); if ( formData.resendDomain.trim() && !formData.resendApiKey.trim() && !emailConfig?.hasApiKey && (mode === "create" || userEnteredDomain) ) { newErrors.resendApiKey = "API key is required when domain is provided"; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm()) { toast.error("Please correct the errors in the form"); return; } setIsSubmitting(true); try { // Format website URL before submission const dataToSubmit = { ...formData, name: formData.name.trim(), nickname: formData.nickname.trim() || undefined, website: formData.website ? formatWebsiteUrl(formData.website) : "", }; if (mode === "create") { // Create business data (excluding email config fields) const businessData = { name: dataToSubmit.name, nickname: dataToSubmit.nickname, email: dataToSubmit.email, phone: dataToSubmit.phone, addressLine1: dataToSubmit.addressLine1, addressLine2: dataToSubmit.addressLine2, city: dataToSubmit.city, state: dataToSubmit.state, postalCode: dataToSubmit.postalCode, country: dataToSubmit.country, website: dataToSubmit.website, taxId: dataToSubmit.taxId, hideNameWithLogo: dataToSubmit.hideNameWithLogo, isDefault: dataToSubmit.isDefault, }; const newBusiness = await createBusiness.mutateAsync(businessData); // Update email configuration separately if any email fields have values const newApiKey = formData.resendApiKey.trim(); const newDomain = formData.resendDomain.trim(); const newFromName = formData.emailFromName.trim(); const hasEmailData = newApiKey || newDomain || newFromName; if (newBusiness && hasEmailData) { await updateEmailConfig.mutateAsync({ id: newBusiness.id, resendApiKey: newApiKey || undefined, resendDomain: newDomain || undefined, emailFromName: newFromName || undefined, }); } toast.success("Business created successfully"); router.push("/dashboard/entities?tab=businesses"); } else { // Update business data (excluding email config fields) const businessData = { name: dataToSubmit.name, nickname: dataToSubmit.nickname, email: dataToSubmit.email, phone: dataToSubmit.phone, addressLine1: dataToSubmit.addressLine1, addressLine2: dataToSubmit.addressLine2, city: dataToSubmit.city, state: dataToSubmit.state, postalCode: dataToSubmit.postalCode, country: dataToSubmit.country, website: dataToSubmit.website, taxId: dataToSubmit.taxId, hideNameWithLogo: dataToSubmit.hideNameWithLogo, isDefault: dataToSubmit.isDefault, }; await updateBusiness.mutateAsync({ id: businessId!, ...businessData, }); // Only update email configuration if there are actual changes or new values const currentApiKey = emailConfig?.hasApiKey ? "EXISTING" : ""; const currentDomain = emailConfig?.resendDomain ?? ""; const currentFromName = emailConfig?.emailFromName ?? ""; const newApiKey = formData.resendApiKey.trim(); const newDomain = formData.resendDomain.trim(); const newFromName = formData.emailFromName.trim(); const hasEmailChanges = (newApiKey && newApiKey !== currentApiKey) || newDomain !== currentDomain || newFromName !== currentFromName; if (hasEmailChanges) { await updateEmailConfig.mutateAsync({ id: businessId!, resendApiKey: newApiKey || undefined, resendDomain: newDomain || undefined, emailFromName: newFromName || undefined, }); } toast.success("Business updated successfully"); router.push("/dashboard/entities?tab=businesses"); } } finally { setIsSubmitting(false); } }; const handleCancel = () => { if (isDirty) { const confirmed = window.confirm( "You have unsaved changes. Are you sure you want to leave?", ); if (!confirmed) return; } router.push("/dashboard/entities?tab=businesses"); }; if ( (mode === "edit" && isLoadingBusiness) || (mode === "edit" && isLoadingEmailConfig) ) { return (
); } return ( <>
{/* Main Form Container - styled like data table */}
{/* Basic Information */}
Basic Information

Enter your business details

handleInputChange("name", e.target.value) } placeholder={PLACEHOLDERS.name} className={`${errors.name ? "border-destructive" : ""}`} disabled={isSubmitting} /> {errors.name && (

{errors.name}

)}
handleInputChange("nickname", e.target.value) } placeholder="e.g., Personal, Work, LLC" disabled={isSubmitting} /> {errors.nickname && (

{errors.nickname}

)}
handleTaxIdChange(e.target.value)} placeholder={PLACEHOLDERS.taxId} className={`${errors.taxId ? "border-destructive" : ""}`} disabled={isSubmitting} maxLength={10} /> {errors.taxId && (

{errors.taxId}

)}
handleInputChange("email", e.target.value) } placeholder={PLACEHOLDERS.email} className={`${errors.email ? "border-destructive" : ""}`} disabled={isSubmitting} /> {errors.email && (

{errors.email}

)}
handlePhoneChange(e.target.value)} placeholder={PLACEHOLDERS.phone} className={`${errors.phone ? "border-destructive" : ""}`} disabled={isSubmitting} /> {errors.phone && (

{errors.phone}

)}
handleInputChange("website", e.target.value) } placeholder={PLACEHOLDERS.website} className={`${errors.website ? "border-destructive" : ""}`} disabled={isSubmitting} /> {errors.website && (

{errors.website}

)}
{/* Logo */} {mode === "edit" && businessId && (
Logo

Shown on invoices sent to your clients. PNG, JPEG, WebP, or SVG, up to 5MB.

{business?.logoStorageKey ? ( // eslint-disable-next-line @next/next/no-img-element -- external/object-storage-backed image, not a static asset {`${business.name} ) : ( )}
{business?.logoStorageKey && ( )}
{business?.logoStorageKey && (

Show only the logo in the invoice header — useful if your logo already includes your business name.

)}
)} {/* Address */}
Business Address

Your business location

{/* Email Configuration */}
Email Configuration

Configure your own Resend API key and domain for sending invoices

{/* Current Status */} {mode === "edit" && (
Current Status: {emailConfig?.hasApiKey && emailConfig?.resendDomain ? ( Custom Configuration Active ) : ( Using System Default )}
{emailConfig?.resendDomain && ( Domain: {emailConfig.resendDomain} )}
)} To use your own email configuration, you'll need to:
  • Create a free account at{" "} resend.com
  • Verify your domain in the Resend dashboard
  • Get your API key from the Resend dashboard
{/* API Key */}
handleInputChange("resendApiKey", e.target.value) } placeholder={ mode === "edit" && emailConfig?.hasApiKey ? "••••••••••••••••••••••••••••••••" : "re_..." } className={ errors.resendApiKey ? "border-destructive" : "" } />
{errors.resendApiKey && (

{errors.resendApiKey}

)}
{/* Domain */}
handleInputChange("resendDomain", e.target.value) } placeholder="yourdomain.com" className={ errors.resendDomain ? "border-destructive" : "" } /> {errors.resendDomain && (

{errors.resendDomain}

)}

This domain must be verified in your Resend account before emails can be sent.

{/* From Name */}
handleInputChange("emailFromName", e.target.value) } placeholder={formData.name || "Your Business Name"} className={ errors.emailFromName ? "border-destructive" : "" } /> {errors.emailFromName && (

{errors.emailFromName}

)}

This will appear as the sender name in emails. Defaults to your business name.

{/* Settings */}
Settings

Configure business preferences

Set this as your default business for new invoices

handleInputChange("isDefault", checked) } disabled={isSubmitting} />

{mode === "create" ? "Creating a new business" : "Editing business details"}

{mode === "create" ? "Complete the form to create your business" : "Update your business information"}

} >
); }