Add business logo branding support

This commit is contained in:
2026-08-14 16:26:43 -04:00
parent 3f3b1362a9
commit 29d7b498ae
31 changed files with 974 additions and 154 deletions
+178
View File
@@ -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>
+3
View File
@@ -25,8 +25,11 @@ interface EmailPreviewProps {
email: string | null;
};
business?: {
id?: string;
name: string;
email: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
};
items?: Array<{
id: string;
@@ -0,0 +1,29 @@
"use client";
// Sets data-color-mode / .dark on <html> from localStorage before paint, to
// avoid a flash of the wrong theme. Rendered only during SSR (typeof window
// check) and returns null on the client, so the <script> element never
// enters the tree React reconciles during hydration — React 19 otherwise
// warns "Encountered a script tag while rendering React component" for any
// <script> it walks while hydrating, even one from next/script. Same fix
// next-themes ships for its inline ThemeScript (shadcn-ui/ui#10238).
const APPEARANCE_INIT_SOURCE = `
try {
var stored = JSON.parse(localStorage.getItem("bv.appearance") || "{}");
var colorMode = stored.colorMode || "system";
var root = document.documentElement;
root.dataset.colorMode = colorMode;
if (colorMode === "dark") root.classList.add("dark");
} catch {}
`;
export function AppearanceInitScript() {
if (typeof window !== "undefined") return null;
return (
<script
id="appearance-init"
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: APPEARANCE_INIT_SOURCE }}
/>
);
}
@@ -19,7 +19,13 @@ export function AppearanceProviderSynced({
}: {
children: React.ReactNode;
}) {
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
// Lazy initializer so the first render already matches what the inline
// appearance-init script set on <html> — a separate mount effect here
// would run one render behind it, transiently flashing (and persisting)
// colorMode back to the default before the effect's own state update lands.
const [colorMode, setColorMode] = useState<ColorMode>(
() => readStoredColorMode() ?? defaultColorMode,
);
const serverHydratedRef = useRef(false);
const utils = api.useUtils();
const updateMutation = api.settings.updateColorMode.useMutation({
@@ -41,14 +47,6 @@ export function AppearanceProviderSynced({
},
);
useEffect(() => {
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(stored);
}
}, []);
useEffect(() => {
if (!serverColorMode?.colorMode) return;
if (serverHydratedRef.current) return;
@@ -63,15 +63,13 @@ export function AppearanceProvider({
}: {
children: React.ReactNode;
}) {
const [colorMode, setColorMode] = useState<ColorMode>(defaultColorMode);
useEffect(() => {
const stored = readStoredColorMode();
if (stored) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setColorMode(stored);
}
}, []);
// Lazy initializer so the first render already matches what the inline
// appearance-init script set on <html> — a separate mount effect here
// would run one render behind it, transiently flashing (and persisting)
// colorMode back to the default before the effect's own state update lands.
const [colorMode, setColorMode] = useState<ColorMode>(
() => readStoredColorMode() ?? defaultColorMode,
);
useEffect(() => {
applyColorMode(colorMode);