Archived
Add business logo branding support
This commit is contained in:
@@ -7,12 +7,15 @@ import {
|
||||
EyeOff,
|
||||
FileText,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Info,
|
||||
Key,
|
||||
Loader2,
|
||||
Mail,
|
||||
Save,
|
||||
Star,
|
||||
Trash2,
|
||||
Upload,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -59,6 +62,7 @@ interface FormData {
|
||||
country: string;
|
||||
website: string;
|
||||
taxId: string;
|
||||
hideNameWithLogo: boolean;
|
||||
isDefault: boolean;
|
||||
resendApiKey: string;
|
||||
resendDomain: string;
|
||||
@@ -95,20 +99,31 @@ const initialFormData: FormData = {
|
||||
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 } =
|
||||
@@ -149,6 +164,62 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -178,6 +249,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
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 ?? "",
|
||||
@@ -338,6 +410,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
country: dataToSubmit.country,
|
||||
website: dataToSubmit.website,
|
||||
taxId: dataToSubmit.taxId,
|
||||
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
|
||||
isDefault: dataToSubmit.isDefault,
|
||||
};
|
||||
|
||||
@@ -376,6 +449,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
country: dataToSubmit.country,
|
||||
website: dataToSubmit.website,
|
||||
taxId: dataToSubmit.taxId,
|
||||
hideNameWithLogo: dataToSubmit.hideNameWithLogo,
|
||||
isDefault: dataToSubmit.isDefault,
|
||||
};
|
||||
|
||||
@@ -649,6 +723,110 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user