c9a664869c
- Replace custom invoice items table with responsive DataTable component - Fix server/client component error by creating InvoiceItemsTable client component - Merge danger zone with actions sidebar and use destructive button variant - Standardize button text sizing across all action buttons - Remove false claims from homepage (testimonials, ratings, fake user counts) - Focus homepage messaging on freelancers with honest feature descriptions - Fix dark mode support throughout app by replacing hard-coded colors with semantic classes - Remove aggressive red styling from settings, add subtle red accents only - Align import/export buttons and improve delete confirmation UX - Update dark mode background to have subtle green tint instead of pure black - Fix HTML nesting error in AlertDialog by using div instead of nested p tags This update makes the invoice view properly responsive, removes misleading marketing claims, and ensures consistent dark mode support across the entire application.
545 lines
18 KiB
TypeScript
545 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
Building,
|
|
Mail,
|
|
Phone,
|
|
Save,
|
|
Globe,
|
|
BadgeDollarSign,
|
|
Image,
|
|
Star,
|
|
Loader2,
|
|
ArrowLeft,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useState, useRef } from "react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import { FormSkeleton } from "~/components/ui/skeleton";
|
|
import { Switch } from "~/components/ui/switch";
|
|
import { AddressForm } from "~/components/forms/address-form";
|
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
|
import { api } from "~/trpc/react";
|
|
import {
|
|
formatPhoneNumber,
|
|
formatWebsiteUrl,
|
|
formatTaxId,
|
|
isValidEmail,
|
|
VALIDATION_MESSAGES,
|
|
PLACEHOLDERS,
|
|
} from "~/lib/form-constants";
|
|
|
|
interface BusinessFormProps {
|
|
businessId?: string;
|
|
mode: "create" | "edit";
|
|
}
|
|
|
|
interface FormData {
|
|
name: string;
|
|
email: string;
|
|
phone: string;
|
|
addressLine1: string;
|
|
addressLine2: string;
|
|
city: string;
|
|
state: string;
|
|
postalCode: string;
|
|
country: string;
|
|
website: string;
|
|
taxId: string;
|
|
logoUrl: string;
|
|
isDefault: boolean;
|
|
}
|
|
|
|
interface FormErrors {
|
|
name?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
addressLine1?: string;
|
|
city?: string;
|
|
state?: string;
|
|
postalCode?: string;
|
|
country?: string;
|
|
website?: string;
|
|
taxId?: string;
|
|
}
|
|
|
|
const initialFormData: FormData = {
|
|
name: "",
|
|
email: "",
|
|
phone: "",
|
|
addressLine1: "",
|
|
addressLine2: "",
|
|
city: "",
|
|
state: "",
|
|
postalCode: "",
|
|
country: "United States",
|
|
website: "",
|
|
taxId: "",
|
|
logoUrl: "",
|
|
isDefault: false,
|
|
};
|
|
|
|
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|
const router = useRouter();
|
|
const [formData, setFormData] = useState<FormData>(initialFormData);
|
|
const [errors, setErrors] = useState<FormErrors>({});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [isDirty, setIsDirty] = useState(false);
|
|
const footerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Fetch business data if editing
|
|
const { data: business, isLoading: isLoadingBusiness } =
|
|
api.businesses.getById.useQuery(
|
|
{ id: businessId! },
|
|
{ enabled: mode === "edit" && !!businessId },
|
|
);
|
|
|
|
const createBusiness = api.businesses.create.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Business created successfully");
|
|
router.push("/dashboard/businesses");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.message || "Failed to create business");
|
|
},
|
|
});
|
|
|
|
const updateBusiness = api.businesses.update.useMutation({
|
|
onSuccess: () => {
|
|
toast.success("Business updated successfully");
|
|
router.push("/dashboard/businesses");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.message || "Failed to update business");
|
|
},
|
|
});
|
|
|
|
// Load business data when editing
|
|
useEffect(() => {
|
|
if (business && mode === "edit") {
|
|
setFormData({
|
|
name: business.name,
|
|
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 ?? "",
|
|
logoUrl: business.logoUrl ?? "",
|
|
isDefault: business.isDefault ?? false,
|
|
});
|
|
}
|
|
}, [business, mode]);
|
|
|
|
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;
|
|
}
|
|
|
|
// Email validation
|
|
if (formData.email && !isValidEmail(formData.email)) {
|
|
newErrors.email = VALIDATION_MESSAGES.email;
|
|
}
|
|
|
|
// Phone validation (basic check for US format)
|
|
if (formData.phone) {
|
|
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
|
|
const hasAddressData =
|
|
formData.addressLine1 ||
|
|
formData.city ||
|
|
formData.state ||
|
|
formData.postalCode;
|
|
|
|
if (hasAddressData) {
|
|
if (!formData.addressLine1)
|
|
newErrors.addressLine1 = VALIDATION_MESSAGES.required;
|
|
if (!formData.city) newErrors.city = VALIDATION_MESSAGES.required;
|
|
if (!formData.country) newErrors.country = VALIDATION_MESSAGES.required;
|
|
|
|
if (formData.country === "United States") {
|
|
if (!formData.state) newErrors.state = VALIDATION_MESSAGES.required;
|
|
if (!formData.postalCode)
|
|
newErrors.postalCode = VALIDATION_MESSAGES.required;
|
|
}
|
|
}
|
|
|
|
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,
|
|
website: formData.website ? formatWebsiteUrl(formData.website) : "",
|
|
};
|
|
|
|
if (mode === "create") {
|
|
await createBusiness.mutateAsync(dataToSubmit);
|
|
} else {
|
|
await updateBusiness.mutateAsync({
|
|
id: businessId!,
|
|
...dataToSubmit,
|
|
});
|
|
}
|
|
} 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/businesses");
|
|
};
|
|
|
|
if (mode === "edit" && isLoadingBusiness) {
|
|
return <FormSkeleton />;
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl">
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Main Form Container - styled like data table */}
|
|
<div className="space-y-4">
|
|
{/* Basic Information */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
|
<Building className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
|
|
</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="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>
|
|
|
|
{/* Address */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
|
<svg
|
|
className="h-5 w-5 text-emerald-700 dark:text-emerald-400"
|
|
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>
|
|
|
|
{/* Settings */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-r from-emerald-600/10 to-teal-600/10">
|
|
<Star className="h-5 w-5 text-emerald-700 dark:text-emerald-400" />
|
|
</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="border-border/40 flex items-center justify-between rounded-xl border bg-gradient-to-r from-emerald-600/5 to-teal-600/5 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 Actions - original position */}
|
|
<div
|
|
ref={footerRef}
|
|
className="border-border/40 bg-background/60 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150"
|
|
>
|
|
<p className="text-muted-foreground text-sm">
|
|
{mode === "create"
|
|
? "Creating a new business"
|
|
: "Editing business details"}
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleCancel}
|
|
disabled={isSubmitting}
|
|
className="border-border/40 hover:bg-accent/50"
|
|
>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting || !isDirty}
|
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{mode === "create" ? "Creating..." : "Saving..."}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
{mode === "create" ? "Create Business" : "Save Changes"}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<FloatingActionBar
|
|
triggerRef={footerRef}
|
|
title={
|
|
mode === "create"
|
|
? "Creating a new business"
|
|
: "Editing business details"
|
|
}
|
|
>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleCancel}
|
|
disabled={isSubmitting}
|
|
className="border-border/40 hover:bg-accent/50"
|
|
>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={isSubmitting || !isDirty}
|
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{mode === "create" ? "Creating..." : "Saving..."}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
{mode === "create" ? "Create Business" : "Save Changes"}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</FloatingActionBar>
|
|
</div>
|
|
);
|
|
}
|