This repository has been archived on 2026-08-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
beenvoice-web/src/components/forms/business-form.tsx
T

1169 lines
42 KiB
TypeScript

"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<FormData>(initialFormData);
const [errors, setErrors] = useState<FormErrors>({});
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<HTMLInputElement>,
) => {
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<string>((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 (
<DashboardPage className="pb-32">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full" />
</div>
</CardContent>
</Card>
</DashboardPage>
);
}
return (
<>
<DashboardPage className="pb-32">
<DashboardPageHeader
title={mode === "edit" ? "Edit Business" : "Add Business"}
description={
mode === "edit"
? "Update business information below"
: "Enter business details below to add a new business."
}
>
<Button
type="submit"
form="business-form"
disabled={isSubmitting}
variant="default"
className="shadow-md"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
<span className="hidden sm:inline">
{mode === "create" ? "Creating..." : "Saving..."}
</span>
</>
) : (
<>
<Save className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">
{mode === "create" ? "Create Business" : "Save Changes"}
</span>
</>
)}
</Button>
</DashboardPageHeader>
<form
id="business-form"
onSubmit={handleSubmit}
className={cn("flex flex-col", dashboardGapClass)}
>
{/* Main Form Container - styled like data table */}
<div className="space-y-4">
{/* Basic Information */}
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<Building className="text-muted-foreground h-5 w-5" />
</div>
<div>
<CardTitle>Basic Information</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Enter your business details
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">
Business Name
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) =>
handleInputChange("name", e.target.value)
}
placeholder={PLACEHOLDERS.name}
className={`${errors.name ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.name && (
<p className="text-destructive text-sm">{errors.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="nickname" className="text-sm font-medium">
Nickname
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="nickname"
value={formData.nickname}
onChange={(e) =>
handleInputChange("nickname", e.target.value)
}
placeholder="e.g., Personal, Work, LLC"
disabled={isSubmitting}
/>
{errors.nickname && (
<p className="text-destructive text-sm">
{errors.nickname}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="taxId" className="text-sm font-medium">
Tax ID (EIN)
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="taxId"
value={formData.taxId}
onChange={(e) => handleTaxIdChange(e.target.value)}
placeholder={PLACEHOLDERS.taxId}
className={`${errors.taxId ? "border-destructive" : ""}`}
disabled={isSubmitting}
maxLength={10}
/>
{errors.taxId && (
<p className="text-destructive text-sm">{errors.taxId}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
Email
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) =>
handleInputChange("email", e.target.value)
}
placeholder={PLACEHOLDERS.email}
className={`${errors.email ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.email && (
<p className="text-destructive text-sm">{errors.email}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone" className="text-sm font-medium">
Phone
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handlePhoneChange(e.target.value)}
placeholder={PLACEHOLDERS.phone}
className={`${errors.phone ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.phone && (
<p className="text-destructive text-sm">{errors.phone}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="website" className="text-sm font-medium">
Website
<span className="text-muted-foreground ml-1 text-xs font-normal">
(Optional)
</span>
</Label>
<Input
id="website"
value={formData.website}
onChange={(e) =>
handleInputChange("website", e.target.value)
}
placeholder={PLACEHOLDERS.website}
className={`${errors.website ? "border-destructive" : ""}`}
disabled={isSubmitting}
/>
{errors.website && (
<p className="text-destructive text-sm">{errors.website}</p>
)}
</div>
</CardContent>
</Card>
{/* Logo */}
{mode === "edit" && businessId && (
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<ImageIcon className="text-muted-foreground h-5 w-5" />
</div>
<div>
<CardTitle>Logo</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Shown on invoices sent to your clients. PNG, JPEG,
WebP, or SVG, up to 5MB.
</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="bg-muted border-border/40 flex h-20 min-w-20 max-w-[240px] shrink-0 items-center justify-center overflow-hidden border px-2">
{business?.logoStorageKey ? (
// eslint-disable-next-line @next/next/no-img-element -- external/object-storage-backed image, not a static asset
<img
src={`/api/business-logo/${businessId}?v=${business.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
alt={`${business.name} logo`}
className="h-full w-auto max-w-full object-contain"
/>
) : (
<ImageIcon className="text-muted-foreground/50 h-8 w-8" />
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
size="sm"
disabled={isUploadingLogo}
onClick={() =>
document.getElementById("logo-upload-input")?.click()
}
>
{isUploadingLogo ? (
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
) : (
<Upload className="h-4 w-4 sm:mr-2" />
)}
<span className="hidden sm:inline">
{business?.logoStorageKey
? "Replace logo"
: "Upload logo"}
</span>
</Button>
{business?.logoStorageKey && (
<Button
type="button"
variant="outline"
size="sm"
disabled={removeLogo.isPending}
onClick={() =>
businessId && removeLogo.mutate({ id: businessId })
}
>
<Trash2 className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Remove</span>
</Button>
)}
<input
id="logo-upload-input"
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={handleLogoFileSelected}
/>
</div>
</div>
{business?.logoStorageKey && (
<div className="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
<div className="space-y-0.5">
<Label
htmlFor="hideNameWithLogo"
className="text-base font-medium"
>
Hide business name on invoices
</Label>
<p className="text-muted-foreground text-sm">
Show only the logo in the invoice header useful
if your logo already includes your business name.
</p>
</div>
<Switch
id="hideNameWithLogo"
checked={formData.hideNameWithLogo}
onCheckedChange={(checked) =>
handleInputChange("hideNameWithLogo", checked)
}
disabled={isSubmitting}
/>
</div>
)}
</CardContent>
</Card>
)}
{/* Address */}
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<svg
className="text-muted-foreground h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div>
<CardTitle>Business Address</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Your business location
</p>
</div>
</div>
</CardHeader>
<CardContent>
<AddressForm
addressLine1={formData.addressLine1}
addressLine2={formData.addressLine2}
city={formData.city}
state={formData.state}
postalCode={formData.postalCode}
country={formData.country}
onChange={handleInputChange}
errors={errors}
required={false}
/>
</CardContent>
</Card>
{/* Email Configuration */}
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<Mail className="text-muted-foreground h-5 w-5" />
</div>
<div>
<CardTitle>Email Configuration</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Configure your own Resend API key and domain for sending
invoices
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Current Status */}
{mode === "edit" && (
<div className="bg-muted/50 flex items-center justify-between p-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Current Status:
</span>
{emailConfig?.hasApiKey && emailConfig?.resendDomain ? (
<Badge
variant="default"
className="bg-primary/10 text-primary"
>
<Key className="mr-1 h-3 w-3" />
Custom Configuration Active
</Badge>
) : (
<Badge variant="secondary">
<Globe className="mr-1 h-3 w-3" />
Using System Default
</Badge>
)}
</div>
{emailConfig?.resendDomain && (
<span className="text-muted-foreground text-sm">
Domain: {emailConfig.resendDomain}
</span>
)}
</div>
)}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
To use your own email configuration, you&apos;ll need to:
<ul className="mt-2 list-inside list-disc space-y-1">
<li>
Create a free account at{" "}
<a
href="https://resend.com"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
resend.com
</a>
</li>
<li>Verify your domain in the Resend dashboard</li>
<li>Get your API key from the Resend dashboard</li>
</ul>
</AlertDescription>
</Alert>
<div className="space-y-4">
{/* API Key */}
<div className="space-y-2">
<Label
htmlFor="resendApiKey"
className="flex items-center gap-2"
>
<Key className="h-4 w-4" />
Resend API Key
{mode === "edit" && emailConfig?.hasApiKey && (
<Badge variant="outline" className="text-xs">
Currently Set
</Badge>
)}
</Label>
<div className="relative">
<Input
id="resendApiKey"
type={showApiKey ? "text" : "password"}
value={formData.resendApiKey}
onChange={(e) =>
handleInputChange("resendApiKey", e.target.value)
}
placeholder={
mode === "edit" && emailConfig?.hasApiKey
? "••••••••••••••••••••••••••••••••"
: "re_..."
}
className={
errors.resendApiKey ? "border-destructive" : ""
}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 p-0"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
{errors.resendApiKey && (
<p className="text-destructive text-sm">
{errors.resendApiKey}
</p>
)}
</div>
{/* Domain */}
<div className="space-y-2">
<Label
htmlFor="resendDomain"
className="flex items-center gap-2"
>
<Globe className="h-4 w-4" />
Verified Domain
</Label>
<Input
id="resendDomain"
type="text"
value={formData.resendDomain}
onChange={(e) =>
handleInputChange("resendDomain", e.target.value)
}
placeholder="yourdomain.com"
className={
errors.resendDomain ? "border-destructive" : ""
}
/>
{errors.resendDomain && (
<p className="text-destructive text-sm">
{errors.resendDomain}
</p>
)}
<p className="text-sm text-gray-600">
This domain must be verified in your Resend account before
emails can be sent.
</p>
</div>
{/* From Name */}
<div className="space-y-2">
<Label
htmlFor="emailFromName"
className="flex items-center gap-2"
>
<User className="h-4 w-4" />
From Name (Optional)
</Label>
<Input
id="emailFromName"
type="text"
value={formData.emailFromName}
onChange={(e) =>
handleInputChange("emailFromName", e.target.value)
}
placeholder={formData.name || "Your Business Name"}
className={
errors.emailFromName ? "border-destructive" : ""
}
/>
{errors.emailFromName && (
<p className="text-destructive text-sm">
{errors.emailFromName}
</p>
)}
<p className="text-sm text-gray-600">
This will appear as the sender name in emails. Defaults to
your business name.
</p>
</div>
</div>
</CardContent>
</Card>
{/* Settings */}
<Card className="bg-card border-border border">
<CardHeader>
<div className="flex items-center gap-3">
<div className="bg-muted flex h-10 w-10 items-center justify-center">
<Star className="text-muted-foreground h-5 w-5" />
</div>
<div>
<CardTitle>Settings</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
Configure business preferences
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted border-border/40 flex items-center justify-between border p-4">
<div className="space-y-0.5">
<Label
htmlFor="isDefault"
className="text-base font-medium"
>
Default Business
</Label>
<p className="text-muted-foreground text-sm">
Set this as your default business for new invoices
</p>
</div>
<Switch
id="isDefault"
checked={formData.isDefault}
onCheckedChange={(checked) =>
handleInputChange("isDefault", checked)
}
disabled={isSubmitting}
/>
</div>
</CardContent>
</Card>
</div>
</form>
</DashboardPage>
<FloatingActionBar
leftContent={
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2">
<FileText className="text-primary h-5 w-5" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">
{mode === "create"
? "Creating a new business"
: "Editing business details"}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
{mode === "create"
? "Complete the form to create your business"
: "Update your business information"}
</p>
</div>
</div>
}
>
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
className="border-border/40 hover:bg-accent/50"
size="sm"
>
<ArrowLeft className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Cancel</span>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !isDirty}
variant="default"
className="shadow-md"
size="sm"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
<span className="hidden sm:inline">
{mode === "create" ? "Creating..." : "Saving..."}
</span>
</>
) : (
<>
<Save className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">
{mode === "create" ? "Create Business" : "Save Changes"}
</span>
</>
)}
</Button>
</FloatingActionBar>
</>
);
}