feat: add business brand assets and health checks

This commit is contained in:
2026-08-17 20:33:04 -04:00
parent 5c9fbe6dc2
commit 7be4bb2abe
36 changed files with 1215 additions and 249 deletions
+79 -36
View File
@@ -1,12 +1,6 @@
import { router } from "expo-router";
import { useState } from "react";
import {
Alert,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
@@ -24,6 +18,10 @@ import { formatCurrency } from "@/lib/format";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
import {
BusinessBrandImage,
hasMobileBusinessBrandAsset,
} from "@/components/businesses/BusinessBrandImage";
type EntityTab = "clients" | "businesses";
@@ -48,7 +46,8 @@ export default function EntitiesScreen() {
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
const isLoading =
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
clientsQuery.isLoading ||
(tab === "businesses" && businessesQuery.isLoading);
if (isLoading) {
return <LoadingScreen message="Loading…" />;
@@ -71,21 +70,27 @@ export default function EntitiesScreen() {
const businesses = businessesQuery.data ?? [];
function refresh() {
return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch();
return tab === "clients"
? clientsQuery.refetch()
: businessesQuery.refetch();
}
function confirmDelete(id: string, name: string) {
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
if (tab === "clients") deleteClient.mutate({ id });
else deleteBusiness.mutate({ id });
Alert.alert(
`Delete ${tab === "clients" ? "client" : "business"}?`,
`Remove ${name}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
if (tab === "clients") deleteClient.mutate({ id });
else deleteBusiness.mutate({ id });
},
},
},
]);
],
);
}
return (
@@ -99,10 +104,7 @@ export default function EntitiesScreen() {
/>
}
refreshControl={
<PullToRefresh
onRefresh={refresh}
tintColor={colors.primary}
/>
<PullToRefresh onRefresh={refresh} tintColor={colors.primary} />
}
>
<ScrollView
@@ -141,7 +143,10 @@ export default function EntitiesScreen() {
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
onPress: () =>
router.push(
`/(app)/entities/clients/edit/${client.id}`,
),
},
{
key: "delete",
@@ -152,7 +157,9 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(client.id, client.name),
},
]}
onPress={() => router.push(`/(app)/entities/clients/${client.id}`)}
onPress={() =>
router.push(`/(app)/entities/clients/${client.id}`)
}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
@@ -162,7 +169,10 @@ export default function EntitiesScreen() {
) : null}
{client.defaultHourlyRate != null ? (
<Text style={styles.meta}>
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
{formatCurrency(
client.defaultHourlyRate,
client.currency ?? "USD",
)}
/hr
</Text>
) : null}
@@ -190,7 +200,10 @@ export default function EntitiesScreen() {
icon: "create-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
onPress: () =>
router.push(
`/(app)/entities/businesses/edit/${business.id}`,
),
},
{
key: "delete",
@@ -201,20 +214,35 @@ export default function EntitiesScreen() {
onPress: () => confirmDelete(business.id, business.name),
},
]}
onPress={() => router.push(`/(app)/entities/businesses/${business.id}`)}
onPress={() =>
router.push(`/(app)/entities/businesses/${business.id}`)
}
>
<GlassSurface style={styles.card}>
<View style={styles.cardInner}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? (
<Text style={styles.badge}>Default</Text>
<View style={styles.businessRow}>
{hasMobileBusinessBrandAsset(business) ? (
<BusinessBrandImage
business={business}
kind="icon"
style={styles.businessIcon}
/>
) : null}
<View style={styles.businessCopy}>
<View style={styles.nameRow}>
<Text style={styles.name}>{business.name}</Text>
{business.isDefault ? (
<Text style={styles.badge}>Default</Text>
) : null}
</View>
{business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text>
) : null}
{business.email ? (
<Text style={styles.meta}>{business.email}</Text>
) : null}
</View>
</View>
{business.nickname ? (
<Text style={styles.meta}>{business.nickname}</Text>
) : null}
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
</View>
</GlassSurface>
</SwipeableRow>
@@ -257,6 +285,21 @@ const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
gap: spacing.sm,
flexWrap: "wrap",
},
businessRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.md,
},
businessCopy: {
flex: 1,
minWidth: 0,
gap: 4,
},
businessIcon: {
width: 44,
height: 44,
borderRadius: 10,
},
name: {
fontSize: 16,
fontFamily: fonts.bodySemiBold,
+24 -2
View File
@@ -8,6 +8,11 @@ import { Logo } from "@/components/Logo";
import { fonts, radii, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
import {
BusinessBrandImage,
hasMobileBusinessBrandAsset,
} from "@/components/businesses/BusinessBrandImage";
import { api } from "@/lib/trpc";
type TopChromeProps = {
showMoreBack?: boolean;
@@ -16,6 +21,7 @@ type TopChromeProps = {
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
export function TopChrome({ showMoreBack = false }: TopChromeProps) {
const { colors, isDark } = useAppTheme();
const defaultBusiness = api.businesses.getDefault.useQuery();
function handleBack() {
if (router.canGoBack()) {
@@ -34,13 +40,25 @@ export function TopChrome({ showMoreBack = false }: TopChromeProps) {
onPress={handleBack}
style={({ pressed }) => [
styles.backButton,
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
{
borderColor: colors.borderGlass,
backgroundColor: colors.cardGlass,
},
pressed && styles.pressed,
]}
>
<Ionicons name="chevron-back" size={18} color={colors.foreground} />
<Text style={[styles.backLabel, { color: colors.foreground }]}>More</Text>
<Text style={[styles.backLabel, { color: colors.foreground }]}>
More
</Text>
</Pressable>
) : defaultBusiness.data &&
hasMobileBusinessBrandAsset(defaultBusiness.data) ? (
<BusinessBrandImage
business={defaultBusiness.data}
kind="wordmark"
style={styles.businessWordmark}
/>
) : (
<Logo size="xs" onDark={isDark} />
)}
@@ -66,6 +84,10 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderRadius: radii.pill,
},
businessWordmark: {
width: 132,
height: 32,
},
backLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 13,
@@ -0,0 +1,53 @@
import { Image, type ImageStyle, type StyleProp } from "react-native";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { hasBusinessBrandAsset } from "@beenvoice/domain/brand-assets";
type BrandAssetKind = "logo" | "wordmark" | "icon";
type BrandAssetTheme = "light" | "dark";
type BusinessBrandImageProps = {
business: {
id: string;
name?: string | null;
logoStorageKey?: string | null;
logoDarkStorageKey?: string | null;
wordmarkLightStorageKey?: string | null;
wordmarkDarkStorageKey?: string | null;
iconLightStorageKey?: string | null;
iconDarkStorageKey?: string | null;
};
kind?: BrandAssetKind;
theme?: BrandAssetTheme;
style?: StyleProp<ImageStyle>;
};
export function hasMobileBusinessBrandAsset(
business: BusinessBrandImageProps["business"],
) {
return hasBusinessBrandAsset(business);
}
export function BusinessBrandImage({
business,
kind = "icon",
theme: themeOverride,
style,
}: BusinessBrandImageProps) {
const { apiUrl } = useAccounts();
const { isDark } = useAppTheme();
if (!hasMobileBusinessBrandAsset(business)) return null;
const theme = themeOverride ?? (isDark ? "dark" : "light");
const base = apiUrl.replace(/\/$/, "");
return (
<Image
source={{
uri: `${base}/api/business-logo/${business.id}?kind=${kind}&theme=${theme}&format=png`,
}}
accessibilityLabel={`${business.name ?? "Business"} ${kind}`}
resizeMode="contain"
style={style}
/>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import * as ImagePicker from "expo-image-picker";
import {
Alert,
KeyboardAvoidingView,
@@ -19,6 +20,12 @@ import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
import { api } from "@/lib/trpc";
import { BusinessBrandImage } from "@/components/businesses/BusinessBrandImage";
import {
getBrandAssetFieldNames,
type BrandAssetKind,
type BrandAssetTheme,
} from "@beenvoice/domain/brand-assets";
type BusinessFormValues = {
name: string;
@@ -33,6 +40,7 @@ type BusinessFormValues = {
country: string;
website: string;
taxId: string;
hideNameWithLogo: boolean;
isDefault: boolean;
};
@@ -49,9 +57,24 @@ const emptyValues: BusinessFormValues = {
country: "United States",
website: "",
taxId: "",
hideNameWithLogo: false,
isDefault: false,
};
const MAX_BRAND_ASSET_BYTES = 5 * 1024 * 1024;
const BRAND_ASSET_SLOTS: Array<{
kind: BrandAssetKind;
theme: BrandAssetTheme;
label: string;
}> = [
{ kind: "logo", theme: "light", label: "Logo · light" },
{ kind: "logo", theme: "dark", label: "Logo · dark" },
{ kind: "wordmark", theme: "light", label: "Wordmark · light" },
{ kind: "wordmark", theme: "dark", label: "Wordmark · dark" },
{ kind: "icon", theme: "light", label: "Icon · light" },
{ kind: "icon", theme: "dark", label: "Icon · dark" },
];
type BusinessFormProps = {
mode: "create" | "edit";
businessId?: string;
@@ -78,6 +101,7 @@ export function BusinessForm({
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
const [fieldError, setFieldError] = useState<string | null>(null);
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
const { touch, visible, markSubmitted } = useFieldVisibility();
const switchProps = {
@@ -102,6 +126,7 @@ export function BusinessForm({
country: business.country ?? "United States",
website: business.website ?? "",
taxId: business.taxId ?? "",
hideNameWithLogo: business.hideNameWithLogo ?? false,
isDefault: business.isDefault ?? false,
});
}, [businessQuery.data]);
@@ -117,7 +142,8 @@ export function BusinessForm({
const updateBusiness = api.businesses.update.useMutation({
onSuccess: () => {
void utils.businesses.getAll.invalidate();
if (businessId) void utils.businesses.getById.invalidate({ id: businessId });
if (businessId)
void utils.businesses.getById.invalidate({ id: businessId });
onSaved();
},
onError: (err) => setFieldError(err.message),
@@ -130,8 +156,68 @@ export function BusinessForm({
},
onError: (err) => Alert.alert("Could not delete business", err.message),
});
const uploadLogo = api.businesses.uploadLogo.useMutation({
onSuccess: () => {
if (businessId)
void utils.businesses.getById.invalidate({ id: businessId });
},
onError: (err) => Alert.alert("Could not upload brand asset", err.message),
onSettled: () => setUploadingAsset(null),
});
const removeLogo = api.businesses.removeLogo.useMutation({
onSuccess: () => {
if (businessId)
void utils.businesses.getById.invalidate({ id: businessId });
},
onError: (err) => Alert.alert("Could not remove brand asset", err.message),
});
function patch<K extends keyof BusinessFormValues>(field: K, value: BusinessFormValues[K]) {
async function pickBrandAsset(kind: BrandAssetKind, theme: BrandAssetTheme) {
if (!businessId) return;
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
Alert.alert(
"Photos access needed",
"Allow photo access to upload branding.",
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 1,
base64: true,
});
if (result.canceled) return;
const asset = result.assets[0];
if (!asset?.base64) return;
const mimeType = asset.mimeType ?? "image/jpeg";
if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
Alert.alert(
"Unsupported image",
"Mobile supports PNG, JPEG, and WebP. Upload SVG assets from the web app.",
);
return;
}
if (asset.fileSize && asset.fileSize > MAX_BRAND_ASSET_BYTES) {
Alert.alert("Image too large", "Brand assets must be 5MB or less.");
return;
}
const key = `${kind}-${theme}`;
setUploadingAsset(key);
uploadLogo.mutate({
id: businessId,
kind,
theme,
filename: asset.fileName ?? `${key}.jpg`,
mimeType,
data: asset.base64,
});
}
function patch<K extends keyof BusinessFormValues>(
field: K,
value: BusinessFormValues[K],
) {
setValues((prev) => ({ ...prev, [field]: value }));
setFieldError(null);
}
@@ -150,6 +236,7 @@ export function BusinessForm({
country: values.country.trim() || "United States",
website: values.website.trim(),
taxId: values.taxId.trim(),
hideNameWithLogo: values.hideNameWithLogo,
isDefault: values.isDefault,
};
}
@@ -186,7 +273,9 @@ export function BusinessForm({
}
const saving = createBusiness.isPending || updateBusiness.isPending;
const nameError = values.name.trim() ? undefined : "Business name is required";
const nameError = values.name.trim()
? undefined
: "Business name is required";
const canSave = isRequiredString(values.name);
return (
@@ -195,8 +284,13 @@ export function BusinessForm({
style={styles.flex}
>
<ScrollView
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
contentContainerStyle={[
styles.container,
{ paddingBottom: scrollPadding },
]}
contentInsetAdjustmentBehavior={
Platform.OS === "ios" ? "automatic" : undefined
}
scrollIndicatorInsets={{ bottom: scrollPadding }}
keyboardShouldPersistTaps="handled"
>
@@ -247,7 +341,9 @@ export function BusinessForm({
<Text style={[styles.switchLabel, { color: colors.foreground }]}>
Default business
</Text>
<Text style={[styles.switchHint, { color: colors.mutedForeground }]}>
<Text
style={[styles.switchHint, { color: colors.mutedForeground }]}
>
Used for new invoices when none is selected
</Text>
</View>
@@ -259,6 +355,108 @@ export function BusinessForm({
</View>
</Card>
{mode === "edit" && businessQuery.data ? (
<Card title="Brand assets">
<Text style={[styles.brandHint, { color: colors.mutedForeground }]}>
Upload combined logos, wordmarks, and compact icons for light and
dark backgrounds. Missing variants fall back automatically.
</Text>
{BRAND_ASSET_SLOTS.map((slot) => {
const [storageField] = getBrandAssetFieldNames(
slot.kind,
slot.theme,
);
const hasAsset = Boolean(businessQuery.data[storageField]);
const key = `${slot.kind}-${slot.theme}`;
return (
<View key={key} style={styles.brandSlot}>
<View
style={[
styles.brandPreview,
{
backgroundColor:
slot.theme === "dark" ? "#09090b" : "#ffffff",
borderColor: colors.border,
},
]}
>
{hasAsset ? (
<BusinessBrandImage
business={businessQuery.data}
kind={slot.kind}
theme={slot.theme}
style={styles.brandImage}
/>
) : (
<Text
style={{
color:
slot.theme === "dark"
? "rgba(255,255,255,0.45)"
: "rgba(0,0,0,0.35)",
}}
>
No asset
</Text>
)}
</View>
<Text
style={[styles.brandLabel, { color: colors.foreground }]}
>
{slot.label}
</Text>
<View style={styles.brandActions}>
<Button
title={hasAsset ? "Replace" : "Upload"}
variant="secondary"
leftIcon="cloud-upload-outline"
loading={uploadingAsset === key}
disabled={Boolean(uploadingAsset)}
style={styles.brandAction}
onPress={() => void pickBrandAsset(slot.kind, slot.theme)}
/>
{hasAsset ? (
<Button
title="Remove"
variant="ghost"
leftIcon="trash-outline"
loading={removeLogo.isPending}
style={styles.brandAction}
onPress={() =>
removeLogo.mutate({
id: businessId!,
kind: slot.kind,
theme: slot.theme,
})
}
/>
) : null}
</View>
</View>
);
})}
<View style={styles.switchRow}>
<View style={styles.switchCopy}>
<Text
style={[styles.switchLabel, { color: colors.foreground }]}
>
Hide business name on invoices
</Text>
<Text
style={[styles.switchHint, { color: colors.mutedForeground }]}
>
Useful when the combined logo already includes the name
</Text>
</View>
<Switch
value={values.hideNameWithLogo}
onValueChange={(value) => patch("hideNameWithLogo", value)}
{...switchProps}
/>
</View>
</Card>
) : null}
<Card title="Address">
<Input
label="Address line 1"
@@ -270,8 +468,16 @@ export function BusinessForm({
value={values.addressLine2}
onChangeText={(v) => patch("addressLine2", v)}
/>
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
<Input
label="City"
value={values.city}
onChangeText={(v) => patch("city", v)}
/>
<Input
label="State"
value={values.state}
onChangeText={(v) => patch("state", v)}
/>
<Input
label="Postal code"
value={values.postalCode}
@@ -284,7 +490,11 @@ export function BusinessForm({
/>
</Card>
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
{fieldError ? (
<Text selectable style={styles.error}>
{fieldError}
</Text>
) : null}
<View style={styles.actions}>
<Button
@@ -337,6 +547,38 @@ const createBusinessFormStyles = (colors: ThemeColors, _isDark: boolean) =>
actions: {
gap: spacing.sm,
},
brandHint: {
fontFamily: fonts.body,
fontSize: 13,
lineHeight: 18,
},
brandSlot: {
gap: spacing.sm,
paddingVertical: spacing.xs,
},
brandPreview: {
height: 92,
borderWidth: 1,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
padding: spacing.md,
},
brandImage: {
width: "100%",
height: "100%",
},
brandLabel: {
fontFamily: fonts.bodySemiBold,
fontSize: 14,
},
brandActions: {
flexDirection: "row",
gap: spacing.sm,
},
brandAction: {
flex: 1,
},
error: {
color: colors.destructive,
fontFamily: fonts.body,
+20 -13
View File
@@ -3,6 +3,8 @@
import { afterEach, describe, expect, test } from "bun:test";
import {
EXPENSE_CATEGORIES as domainExpenseCategories,
addCalendarDays,
calendarDateFromInstant,
formatElapsedSeconds as formatDomainElapsedSeconds,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
@@ -14,9 +16,7 @@ import { generateInvoiceNumber as generateMobileInvoiceNumber } from "../lib/inv
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
import {
normalizeOptionalId,
} from "../../web/src/lib/time-clock";
import { normalizeOptionalId } from "../../web/src/lib/time-clock";
const originalFetch = globalThis.fetch;
@@ -52,15 +52,18 @@ describe("invoice parity", () => {
test("web and mobile use the device-local date in invoice numbers", () => {
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith(
"INV-20260816-",
);
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith(
"INV-20260816-",
);
});
test("web and mobile agree on draft, paid, sent, and overdue states", () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const timeZone = "America/New_York";
const today = calendarDateFromInstant(new Date(), timeZone);
const yesterday = addCalendarDays(today, -1);
const fixtures = [
{
@@ -82,11 +85,15 @@ describe("invoice parity", () => {
];
for (const fixture of fixtures) {
expect(getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate)).toBe(
fixture.expected,
);
expect(
getInvoiceStatus({ status: fixture.stored, dueDate: fixture.dueDate }),
getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate, timeZone),
).toBe(fixture.expected);
expect(
getInvoiceStatus({
status: fixture.stored,
dueDate: fixture.dueDate,
createdBy: { timeZone },
}),
).toBe(fixture.expected);
}
});
@@ -0,0 +1,10 @@
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoDarkStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "logoDarkMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkLightStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkLightMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkDarkStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "wordmarkDarkMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconLightStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconLightMimeType" varchar(100);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconDarkStorageKey" varchar(500);
ALTER TABLE "beenvoice_business" ADD COLUMN IF NOT EXISTS "iconDarkMimeType" varchar(100);
+7
View File
@@ -225,6 +225,13 @@
"when": 1786950000000,
"tag": "0031_timezone_safety",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1786975200000,
"tag": "0032_business_brand_assets",
"breakpoints": true
}
]
}
@@ -3,6 +3,13 @@ import { eq } from "drizzle-orm";
import { getObject } from "~/lib/object-storage";
import { db } from "~/server/db";
import { businesses } from "~/server/db/schema";
import {
brandAssetKinds,
brandAssetThemes,
resolveBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
} from "~/lib/business-branding";
export const runtime = "nodejs";
@@ -16,27 +23,57 @@ export async function GET(
{ params }: { params: Promise<{ businessId: string }> },
) {
const { businessId } = await params;
const url = new URL(req.url);
const requestedKind = url.searchParams.get("kind");
const requestedTheme = url.searchParams.get("theme");
const kind: BrandAssetKind = brandAssetKinds.includes(
requestedKind as BrandAssetKind,
)
? (requestedKind as BrandAssetKind)
: "logo";
const theme: BrandAssetTheme = brandAssetThemes.includes(
requestedTheme as BrandAssetTheme,
)
? (requestedTheme as BrandAssetTheme)
: "light";
const business = await db.query.businesses.findFirst({
where: eq(businesses.id, businessId),
columns: { logoStorageKey: true, logoMimeType: true },
columns: {
logoStorageKey: true,
logoMimeType: true,
logoDarkStorageKey: true,
logoDarkMimeType: true,
wordmarkLightStorageKey: true,
wordmarkLightMimeType: true,
wordmarkDarkStorageKey: true,
wordmarkDarkMimeType: true,
iconLightStorageKey: true,
iconLightMimeType: true,
iconDarkStorageKey: true,
iconDarkMimeType: true,
},
});
const asset = business
? resolveBusinessBrandAsset(business, kind, theme)
: null;
if (!business?.logoStorageKey || !business.logoMimeType) {
if (!asset) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
// generation requests a rasterized copy of SVG/WebP logos via this param.
const wantsPng =
new URL(req.url).searchParams.get("format") === "png" &&
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
url.searchParams.get("format") === "png" &&
RASTERIZABLE_MIME_TYPES.has(asset.mimeType);
try {
const body = await getObject(business.logoStorageKey);
const body = await getObject(asset.storageKey);
if (wantsPng) {
const { default: sharp } = await import("sharp");
const isSvg = business.logoMimeType === "image/svg+xml";
const isSvg = asset.mimeType === "image/svg+xml";
// SVG is vector: rasterize at a high density so the PNG stays crisp at
// the size it's actually displayed (PDF header, up to ~2.2in wide).
// withoutEnlargement only makes sense for the WebP (already-raster)
@@ -62,7 +99,7 @@ export async function GET(
return new NextResponse(new Uint8Array(body), {
headers: {
"Content-Type": business.logoMimeType,
"Content-Type": asset.mimeType,
"Cache-Control": "public, max-age=300, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
@@ -71,6 +108,8 @@ export async function GET(
console.error("[business-logo] Failed to serve logo", {
backendError: error,
businessId,
kind,
theme,
wantsPng,
});
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
+30
View File
@@ -0,0 +1,30 @@
import { sql } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "~/server/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
await Promise.race([
db.execute(sql`select 1`),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Database readiness timed out")),
2_000,
),
),
]);
return NextResponse.json(
{ status: "ok", database: "ready" },
{ headers: { "Cache-Control": "no-store" } },
);
} catch {
return NextResponse.json(
{ status: "unavailable", database: "unavailable" },
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
}
}
+18 -2
View File
@@ -35,6 +35,16 @@ export async function GET(
taxId: true,
logoStorageKey: true,
logoMimeType: true,
logoDarkStorageKey: true,
logoDarkMimeType: true,
wordmarkLightStorageKey: true,
wordmarkLightMimeType: true,
wordmarkDarkStorageKey: true,
wordmarkDarkMimeType: true,
iconLightStorageKey: true,
iconLightMimeType: true,
iconDarkStorageKey: true,
iconDarkMimeType: true,
hideNameWithLogo: true,
},
},
@@ -52,8 +62,14 @@ export async function GET(
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
return NextResponse.json({ error: "This link has expired" }, { status: 410 });
if (
invoice.publicTokenExpiresAt &&
new Date(invoice.publicTokenExpiresAt) < new Date()
) {
return NextResponse.json(
{ error: "This link has expired" },
{ status: 410 },
);
}
const settings = await db.query.platformSettings.findFirst({
@@ -24,6 +24,8 @@ import {
Hash,
ArrowLeft,
} from "lucide-react";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
interface BusinessDetailPageProps {
params: Promise<{ id: string }>;
@@ -74,13 +76,12 @@ export default async function BusinessDetailPage({
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{business.logoStorageKey ? (
{hasBusinessBrandAsset(business) ? (
<div className="bg-muted border-border/40 flex h-9 max-w-32 shrink-0 items-center justify-center overflow-hidden border px-1.5 py-1">
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
<img
src={`/api/business-logo/${business.id}`}
alt={`${business.name} logo`}
className="h-full w-auto max-w-full object-contain"
<BusinessBrandImage
business={business}
kind="icon"
className="h-full w-full"
/>
</div>
) : (
@@ -17,6 +17,8 @@ import {
} from "~/components/ui/dialog";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
// Type for business data
interface Business {
@@ -35,6 +37,17 @@ interface Business {
taxId: string | null;
logoUrl: string | null;
logoStorageKey: string | null;
logoMimeType: string | null;
logoDarkStorageKey: string | null;
logoDarkMimeType: string | null;
wordmarkLightStorageKey: string | null;
wordmarkLightMimeType: string | null;
wordmarkDarkStorageKey: string | null;
wordmarkDarkMimeType: string | null;
iconLightStorageKey: string | null;
iconLightMimeType: string | null;
iconDarkStorageKey: string | null;
iconDarkMimeType: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;
@@ -88,12 +101,12 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
return (
<div className="flex items-center gap-3">
<div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
{business.logoStorageKey ? (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset
<img
src={`/api/business-logo/${business.id}`}
alt=""
className="h-full w-full object-contain"
{hasBusinessBrandAsset(business) ? (
<BusinessBrandImage
business={business}
kind="icon"
decorative
className="h-full w-full"
/>
) : (
<Building className="text-primary h-4 w-4" />
@@ -28,6 +28,8 @@ import {
toZonedDateTimeInputValue,
zonedDateTimeToInstant,
} from "@beenvoice/domain/time-zone";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
import Link from "next/link";
import {
notFound,
@@ -424,13 +426,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoice.business.logoStorageKey && (
{hasBusinessBrandAsset(invoice.business) && (
<div className="bg-muted border-border/40 flex h-12 w-fit max-w-40 items-center justify-center overflow-hidden border px-2 py-1.5">
{/* eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset */}
<img
src={`/api/business-logo/${invoice.business.id}`}
alt={`${invoice.business.name} logo`}
className="h-full w-auto max-w-full object-contain"
<BusinessBrandImage
business={invoice.business}
kind="logo"
className="h-full max-w-36 min-w-20"
/>
</div>
)}
@@ -264,6 +264,19 @@ export default function SendEmailPage() {
email: invoiceData.business.email,
logoStorageKey: invoiceData.business.logoStorageKey,
logoMimeType: invoiceData.business.logoMimeType,
logoDarkStorageKey: invoiceData.business.logoDarkStorageKey,
logoDarkMimeType: invoiceData.business.logoDarkMimeType,
wordmarkLightStorageKey:
invoiceData.business.wordmarkLightStorageKey,
wordmarkLightMimeType:
invoiceData.business.wordmarkLightMimeType,
wordmarkDarkStorageKey:
invoiceData.business.wordmarkDarkStorageKey,
wordmarkDarkMimeType: invoiceData.business.wordmarkDarkMimeType,
iconLightStorageKey: invoiceData.business.iconLightStorageKey,
iconLightMimeType: invoiceData.business.iconLightMimeType,
iconDarkStorageKey: invoiceData.business.iconDarkStorageKey,
iconDarkMimeType: invoiceData.business.iconDarkMimeType,
}
: undefined,
items: invoiceData.items?.map((item) => ({
+9 -8
View File
@@ -13,6 +13,8 @@ import {
formatCalendarDate,
getEffectiveInvoiceStatus,
} from "@beenvoice/domain";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
function formatDate(date: Date) {
return formatCalendarDate(date, {
@@ -121,7 +123,7 @@ function PublicInvoiceView({ token }: { token: string }) {
? `${invoice.business.name} (${invoice.business.nickname})`
: invoice.business.name
: null;
const hasLogo = Boolean(invoice.business?.logoStorageKey);
const hasLogo = hasBusinessBrandAsset(invoice.business);
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
return (
@@ -132,13 +134,12 @@ function PublicInvoiceView({ token }: { token: string }) {
{/* Header */}
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
{hasLogo && (
// Uploaded SVGs are sanitized and served by our route. next/image's
// optimizer intentionally rejects SVG, so a native img is required.
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/business-logo/${invoice.business!.id}`}
alt=""
className="h-16 w-auto max-w-[220px] shrink-0 rounded bg-white object-contain px-2 py-1.5"
<BusinessBrandImage
business={invoice.business!}
kind="logo"
theme="dark"
decorative
className="h-16 w-[220px] max-w-[42%] shrink-0 rounded px-2 py-1.5"
/>
)}
<div className="min-w-0">
@@ -0,0 +1,54 @@
import { cn } from "~/lib/utils";
import {
businessBrandAssetPath,
hasBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
type BusinessBrandAssets,
} from "~/lib/business-branding";
type BusinessBrandImageProps = {
business: BusinessBrandAssets & { id: string; name?: string | null };
kind?: BrandAssetKind;
theme?: BrandAssetTheme | "auto";
className?: string;
imageClassName?: string;
decorative?: boolean;
};
export function BusinessBrandImage({
business,
kind = "icon",
theme = "auto",
className,
imageClassName,
decorative = false,
}: BusinessBrandImageProps) {
if (!hasBusinessBrandAsset(business)) return null;
const alt = decorative ? "" : `${business.name ?? "Business"} ${kind}`;
const image = (variant: BrandAssetTheme, variantClassName?: string) => (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed SVG/raster brand asset
<img
src={businessBrandAssetPath(business.id, kind, variant)}
alt={alt}
className={cn(
"h-full w-full object-contain",
imageClassName,
variantClassName,
)}
/>
);
return (
<span className={cn("block overflow-hidden", className)}>
{theme === "auto" ? (
<>
{image("light", "dark:hidden")}
{image("dark", "hidden dark:block")}
</>
) : (
image(theme)
)}
</span>
);
}
@@ -0,0 +1,21 @@
"use client";
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
import { Logo } from "~/components/branding/logo";
import { hasBusinessBrandAsset } from "~/lib/business-branding";
import { api } from "~/trpc/react";
export function DashboardBrand({ compact = false }: { compact?: boolean }) {
const { data: business } = api.businesses.getDefault.useQuery();
if (business && hasBusinessBrandAsset(business)) {
return (
<BusinessBrandImage
business={business}
kind={compact ? "icon" : "wordmark"}
decorative
className={compact ? "h-8 w-8" : "h-8 w-36"}
/>
);
}
return <Logo size={compact ? "icon" : "sm"} />;
}
+186 -75
View File
@@ -24,7 +24,10 @@ 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 {
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";
@@ -43,6 +46,13 @@ import {
VALIDATION_MESSAGES,
} from "~/lib/form-constants";
import { api } from "~/trpc/react";
import {
businessBrandAssetPath,
getBrandAssetFieldNames,
hasBusinessBrandAsset,
type BrandAssetKind,
type BrandAssetTheme,
} from "~/lib/business-branding";
interface BusinessFormProps {
businessId?: string;
@@ -114,6 +124,54 @@ const ACCEPTED_LOGO_TYPES = new Set([
"image/svg+xml",
]);
const BRAND_ASSET_SLOTS: Array<{
kind: BrandAssetKind;
theme: BrandAssetTheme;
label: string;
description: string;
}> = [
{
kind: "logo",
theme: "light",
label: "Logo · light",
description: "Icon + text for light backgrounds",
},
{
kind: "logo",
theme: "dark",
label: "Logo · dark",
description: "Icon + text for dark backgrounds",
},
{
kind: "wordmark",
theme: "light",
label: "Wordmark · light",
description: "Text-only mark for light backgrounds",
},
{
kind: "wordmark",
theme: "dark",
label: "Wordmark · dark",
description: "Text-only mark for dark backgrounds",
},
{
kind: "icon",
theme: "light",
label: "Icon · light",
description: "Compact mark for light UI",
},
{
kind: "icon",
theme: "dark",
label: "Icon · dark",
description: "Compact mark for dark UI",
},
];
function assetSlotKey(kind: BrandAssetKind, theme: BrandAssetTheme) {
return `${kind}-${theme}`;
}
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const router = useRouter();
const utils = api.useUtils();
@@ -123,7 +181,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const [showApiKey, setShowApiKey] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [initialized, setInitialized] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
// Fetch business data if editing
const { data: business, isLoading: isLoadingBusiness } =
@@ -165,20 +223,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
});
const uploadLogo = api.businesses.uploadLogo.useMutation({
onSuccess: async () => {
onSuccess: async (_data, variables) => {
await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo updated");
toast.success(`${variables.kind} ${variables.theme} variant updated`);
},
onError: (error) => {
toast.error(error.message || "Failed to upload logo");
},
onSettled: () => setIsUploadingLogo(false),
onSettled: () => setUploadingAsset(null),
});
const removeLogo = api.businesses.removeLogo.useMutation({
onSuccess: async () => {
onSuccess: async (_data, variables) => {
await utils.businesses.getById.invalidate({ id: businessId });
toast.success("Logo removed");
toast.success(`${variables.kind} ${variables.theme} variant removed`);
},
onError: (error) => {
toast.error(error.message || "Failed to remove logo");
@@ -187,6 +245,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
const handleLogoFileSelected = async (
e: React.ChangeEvent<HTMLInputElement>,
kind: BrandAssetKind,
theme: BrandAssetTheme,
) => {
const file = e.target.files?.[0];
e.target.value = "";
@@ -201,7 +261,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
return;
}
setIsUploadingLogo(true);
setUploadingAsset(assetSlotKey(kind, theme));
const data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -217,6 +277,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
filename: file.name,
mimeType: file.type,
data,
kind,
theme,
});
};
@@ -229,12 +291,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
useEffect(() => {
if (
business &&
mode === "edit" &&
!initialized &&
!isLoadingEmailConfig
) {
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,
@@ -732,74 +789,128 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
<ImageIcon className="text-muted-foreground h-5 w-5" />
</div>
<div>
<CardTitle>Logo</CardTitle>
<CardTitle>Brand assets</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.
Add logos, wordmarks, and icons for light and dark
backgrounds. Missing variants fall back automatically.
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 })
}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{BRAND_ASSET_SLOTS.map((slot) => {
const [storageField] = getBrandAssetFieldNames(
slot.kind,
slot.theme,
);
const hasAsset = Boolean(business?.[storageField]);
const key = assetSlotKey(slot.kind, slot.theme);
const inputId = `brand-asset-${key}`;
const isUploading = uploadingAsset === key;
return (
<div
key={key}
className="border-border/60 overflow-hidden rounded-xl border"
>
<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
className={cn(
"flex h-28 items-center justify-center p-4",
slot.theme === "dark"
? "bg-neutral-950"
: "bg-white",
)}
>
{hasAsset ? (
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image
<img
src={`${businessBrandAssetPath(businessId, slot.kind, slot.theme)}&v=${business?.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
alt={`${business?.name ?? "Business"} ${slot.label}`}
className={cn(
"max-h-full max-w-full object-contain",
slot.kind === "icon" && "aspect-square",
)}
/>
) : (
<ImageIcon
className={cn(
"h-8 w-8",
slot.theme === "dark"
? "text-white/35"
: "text-black/25",
)}
/>
)}
</div>
<div className="space-y-3 p-3">
<div>
<p className="text-sm font-medium">
{slot.label}
</p>
<p className="text-muted-foreground text-xs">
{slot.description}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
disabled={Boolean(uploadingAsset)}
onClick={() =>
document.getElementById(inputId)?.click()
}
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{hasAsset ? "Replace" : "Upload"}
</Button>
{hasAsset ? (
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={`Remove ${slot.label}`}
disabled={removeLogo.isPending}
onClick={() =>
businessId &&
removeLogo.mutate({
id: businessId,
kind: slot.kind,
theme: slot.theme,
})
}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
<input
id={inputId}
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
className="hidden"
onChange={(event) =>
void handleLogoFileSelected(
event,
slot.kind,
slot.theme,
)
}
/>
</div>
</div>
</div>
);
})}
</div>
{business?.logoStorageKey && (
{hasBusinessBrandAsset(business) && (
<div className="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
<div className="space-y-0.5">
<Label
@@ -809,8 +920,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
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.
Show only the logo in the invoice header useful if
your logo already includes your business name.
</p>
</div>
<Switch
@@ -3,6 +3,7 @@
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
import { getAppUrl } from "~/lib/app-url";
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
import type { BusinessBrandAssets } from "~/lib/business-branding";
interface EmailPreviewProps {
subject: string;
@@ -28,9 +29,7 @@ interface EmailPreviewProps {
id?: string;
name: string;
email: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
};
} & BusinessBrandAssets;
items?: Array<{
id: string;
date?: Date;
@@ -87,7 +86,8 @@ export function EmailPreview({
description: item.description ?? "Service",
hours: item.hours,
rate: item.rate,
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
amount:
item.amount ?? calculateLineItemAmount(item.hours, item.rate),
})) ?? [],
},
customContent: content,
@@ -9,7 +9,7 @@ import {
} from "~/components/layout/sidebar-provider";
import { cn } from "~/lib/utils";
import { Menu } from "lucide-react";
import { Logo } from "~/components/branding/logo";
import { DashboardBrand } from "~/components/branding/dashboard-brand";
import { Button } from "~/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
@@ -48,7 +48,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
</Button>
</SheetTrigger>
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
<Logo size="sm" className="shrink-0" />
<DashboardBrand />
<ActiveTimerWidget compact />
</div>
<SheetContent side="left" className="w-72 p-0">
+3 -3
View File
@@ -9,7 +9,7 @@ import { LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
import { useSidebar } from "./sidebar-provider";
import { cn } from "~/lib/utils";
import { Logo } from "~/components/branding/logo";
import { DashboardBrand } from "~/components/branding/dashboard-brand";
import {
Tooltip,
TooltipContent,
@@ -57,10 +57,10 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
>
{!collapsed && (
<div className="flex items-center gap-2">
<Logo size="sm" />
<DashboardBrand />
</div>
)}
{collapsed && <Logo size="icon" />}
{collapsed && <DashboardBrand compact />}
{!mobile && !collapsed && (
<div className="h-8 w-8" /> // Spacer to keep alignment if needed, or just remove
+28
View File
@@ -0,0 +1,28 @@
export {
brandAssetKinds,
brandAssetThemes,
getBrandAssetFieldNames,
hasBusinessBrandAsset,
resolveBusinessBrandAsset,
} from "@beenvoice/domain/brand-assets";
export type {
BrandAssetKind,
BrandAssetTheme,
BusinessBrandAssets,
} from "@beenvoice/domain/brand-assets";
import type {
BrandAssetKind,
BrandAssetTheme,
} from "@beenvoice/domain/brand-assets";
export function businessBrandAssetPath(
businessId: string,
kind: BrandAssetKind = "logo",
theme: BrandAssetTheme = "light",
format?: "png",
): string {
const params = new URLSearchParams({ kind, theme });
if (format) params.set("format", format);
return `/api/business-logo/${businessId}?${params.toString()}`;
}
@@ -1,5 +1,10 @@
import { getAppUrl } from "~/lib/app-url";
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
import {
businessBrandAssetPath,
resolveBusinessBrandAsset,
type BusinessBrandAssets,
} from "~/lib/business-branding";
// Most email clients render <img src> fine for PNG/JPEG but are inconsistent
// with SVG (Outlook and several webmail clients strip or refuse it), so
@@ -7,20 +12,23 @@ import { formatCalendarDate } from "@beenvoice/domain/time-zone";
// rasterization the PDF export uses.
function resolveEmailLogoUrl(
business:
| {
| ({
id?: string;
logoStorageKey?: string | null;
logoMimeType?: string | null;
}
} & BusinessBrandAssets)
| null
| undefined,
baseUrl: string,
): string | null {
if (!business?.id || !business.logoStorageKey) return null;
const needsRaster =
business.logoMimeType != null &&
!["image/png", "image/jpeg"].includes(business.logoMimeType);
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
if (!business?.id) return null;
const asset = resolveBusinessBrandAsset(business, "logo", "light");
if (!asset) return null;
const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
const path = businessBrandAssetPath(
business.id,
"logo",
"light",
needsRaster ? "png" : undefined,
);
return `${baseUrl.replace(/\/$/, "")}${path}`;
}
@@ -37,21 +45,21 @@ interface InvoiceEmailTemplateProps {
name: string;
email: string | null;
};
business?: {
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
} | null;
business?:
| ({
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
} & BusinessBrandAssets)
| null;
items: Array<{
date: Date;
description: string;
+33 -23
View File
@@ -18,6 +18,11 @@ import {
pdfFontCacheKey,
resolvePdfFonts,
} from "~/lib/pdf-fonts";
import {
businessBrandAssetPath,
resolveBusinessBrandAsset,
type BusinessBrandAssets,
} from "~/lib/business-branding";
// Fallback download function for better browser compatibility
function downloadBlob(blob: Blob, filename: string): void {
@@ -73,24 +78,24 @@ export interface InvoiceData {
taxRate: number;
currency?: string | null;
notes?: string | null;
business?: {
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
website?: string | null;
taxId?: string | null;
logoStorageKey?: string | null;
logoMimeType?: string | null;
hideNameWithLogo?: boolean | null;
} | null;
business?:
| ({
id?: string;
name: string;
nickname?: string | null;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
website?: string | null;
taxId?: string | null;
hideNameWithLogo?: boolean | null;
} & BusinessBrandAssets)
| null;
client?: {
name: string;
email?: string | null;
@@ -848,12 +853,17 @@ function resolveBusinessLogoSrc(
business: InvoiceData["business"],
baseUrlOverride?: string,
): string | null {
if (!business?.id || !business.logoStorageKey) return null;
if (!business?.id) return null;
const asset = resolveBusinessBrandAsset(business, "logo", "light");
if (!asset) return null;
const needsRaster =
business.logoMimeType != null &&
!["image/png", "image/jpeg"].includes(business.logoMimeType);
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
const path = businessBrandAssetPath(
business.id,
"logo",
"light",
needsRaster ? "png" : undefined,
);
if (typeof window !== "undefined") {
return `${window.location.origin}${path}`;
+1
View File
@@ -23,6 +23,7 @@ export function proxy(request: NextRequest) {
"/api/mcp",
"/api/i",
"/api/business-logo",
"/api/health",
];
// Allow API routes to pass through
+59 -16
View File
@@ -7,6 +7,11 @@ import { invoices } from "~/server/db/schema";
import { sql } from "drizzle-orm";
import { deleteObject, putObject } from "~/lib/object-storage";
import { sanitizeSvg } from "~/lib/svg-sanitize";
import {
brandAssetKinds,
brandAssetThemes,
getBrandAssetFieldNames,
} from "~/lib/business-branding";
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
const allowedLogoMimeTypes = new Set([
@@ -287,6 +292,7 @@ export const businessesRouter = createTRPCRouter({
"Business not found or you don't have permission to delete it",
);
}
const existingBusiness = business[0];
// Check if this business has any invoices
const invoiceCount = await ctx.db
@@ -300,6 +306,13 @@ export const businessesRouter = createTRPCRouter({
);
}
const storageKeys = brandAssetKinds.flatMap((kind) =>
brandAssetThemes.flatMap((theme) => {
const [storageField] = getBrandAssetFieldNames(kind, theme);
const storageKey = existingBusiness[storageField];
return storageKey ? [storageKey] : [];
}),
);
await ctx.db
.delete(businesses)
.where(
@@ -309,6 +322,12 @@ export const businessesRouter = createTRPCRouter({
),
);
await Promise.all(
[...new Set(storageKeys)].map((key) =>
deleteObject(key).catch(() => undefined),
),
);
return { success: true };
}),
@@ -432,7 +451,8 @@ export const businessesRouter = createTRPCRouter({
};
}),
// Upload (or replace) a business logo, shown on invoices
// Upload (or replace) a business brand asset. Kind/theme default to the
// legacy combined/light logo so older clients remain compatible.
uploadLogo: protectedProcedure
.input(
z.object({
@@ -440,6 +460,8 @@ export const businessesRouter = createTRPCRouter({
filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(100),
data: z.string().min(1),
kind: z.enum(brandAssetKinds).default("logo"),
theme: z.enum(brandAssetThemes).default("light"),
}),
)
.mutation(async ({ ctx, input }) => {
@@ -457,7 +479,8 @@ export const businessesRouter = createTRPCRouter({
if (!business) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Business not found or you don't have permission to update it",
message:
"Business not found or you don't have permission to update it",
});
}
@@ -465,7 +488,7 @@ export const businessesRouter = createTRPCRouter({
if (!allowedLogoMimeTypes.has(mimeType)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Logo must be a PNG, JPEG, WebP, or SVG image",
message: "Brand asset must be a PNG, JPEG, WebP, or SVG image",
});
}
@@ -473,7 +496,7 @@ export const businessesRouter = createTRPCRouter({
if (!body.length || body.length > MAX_LOGO_BYTES) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Logo must be between 1 byte and 5MB",
message: "Brand asset must be between 1 byte and 5MB",
});
}
@@ -482,20 +505,24 @@ export const businessesRouter = createTRPCRouter({
}
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${crypto.randomUUID()}-${safeName}`;
const previousStorageKey = business.logoStorageKey;
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${input.kind}/${input.theme}/${crypto.randomUUID()}-${safeName}`;
const [storageField, mimeField] = getBrandAssetFieldNames(
input.kind,
input.theme,
);
const previousStorageKey = business[storageField];
try {
await putObject(storageKey, body, mimeType);
} catch (error) {
console.error("[businesses.uploadLogo] Failed to store logo", {
console.error("[businesses.uploadLogo] Failed to store brand asset", {
backendError: error,
businessId: business.id,
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"Logo storage is unavailable. Check the object-storage service and try again.",
"Brand asset storage is unavailable. Check the object-storage service and try again.",
cause: error,
});
}
@@ -503,8 +530,8 @@ export const businessesRouter = createTRPCRouter({
const [updatedBusiness] = await ctx.db
.update(businesses)
.set({
logoStorageKey: storageKey,
logoMimeType: mimeType,
[storageField]: storageKey,
[mimeField]: mimeType,
updatedAt: new Date(),
})
.where(eq(businesses.id, business.id))
@@ -517,9 +544,15 @@ export const businessesRouter = createTRPCRouter({
return updatedBusiness;
}),
// Remove a business logo
// Remove one business brand asset. Defaults preserve older clients.
removeLogo: protectedProcedure
.input(z.object({ id: z.string() }))
.input(
z.object({
id: z.string(),
kind: z.enum(brandAssetKinds).default("logo"),
theme: z.enum(brandAssetThemes).default("light"),
}),
)
.mutation(async ({ ctx, input }) => {
const [business] = await ctx.db
.select()
@@ -535,17 +568,27 @@ export const businessesRouter = createTRPCRouter({
if (!business) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Business not found or you don't have permission to update it",
message:
"Business not found or you don't have permission to update it",
});
}
if (business.logoStorageKey) {
await deleteObject(business.logoStorageKey).catch(() => undefined);
const [storageField, mimeField] = getBrandAssetFieldNames(
input.kind,
input.theme,
);
const storageKey = business[storageField];
if (storageKey) {
await deleteObject(storageKey).catch(() => undefined);
}
const [updatedBusiness] = await ctx.db
.update(businesses)
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() })
.set({
[storageField]: null,
[mimeField]: null,
updatedAt: new Date(),
})
.where(eq(businesses.id, business.id))
.returning();
@@ -1124,6 +1124,16 @@ export const invoicesRouter = createTRPCRouter({
taxId: true,
logoStorageKey: true,
logoMimeType: true,
logoDarkStorageKey: true,
logoDarkMimeType: true,
wordmarkLightStorageKey: true,
wordmarkLightMimeType: true,
wordmarkDarkStorageKey: true,
wordmarkDarkMimeType: true,
iconLightStorageKey: true,
iconLightMimeType: true,
iconDarkStorageKey: true,
iconDarkMimeType: true,
hideNameWithLogo: true,
},
},
+19 -5
View File
@@ -1212,14 +1212,21 @@ export const settingsRouter = createTRPCRouter({
.mutation(async ({ ctx }) => {
const userId = ctx.session.user.id;
const [receiptObjects, logoObjects] = await Promise.all([
const [receiptObjects, brandObjects] = await Promise.all([
ctx.db
.select({ storageKey: expenseReceipts.storageKey })
.from(expenseReceipts)
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
.where(eq(expenses.createdById, userId)),
ctx.db
.select({ storageKey: businesses.logoStorageKey })
.select({
logo: businesses.logoStorageKey,
logoDark: businesses.logoDarkStorageKey,
wordmarkLight: businesses.wordmarkLightStorageKey,
wordmarkDark: businesses.wordmarkDarkStorageKey,
iconLight: businesses.iconLightStorageKey,
iconDark: businesses.iconDarkStorageKey,
})
.from(businesses)
.where(eq(businesses.createdById, userId)),
]);
@@ -1227,9 +1234,16 @@ export const settingsRouter = createTRPCRouter({
// Delete uploaded personal data before removing its database pointers. If object
// storage is unavailable, the account remains intact so the user can retry.
await Promise.all(
[...receiptObjects, ...logoObjects].flatMap(({ storageKey }) =>
storageKey ? [deleteObject(storageKey)] : [],
),
[
...receiptObjects.flatMap(({ storageKey }) =>
storageKey ? [storageKey] : [],
),
...brandObjects.flatMap((assets) =>
Object.values(assets).filter((storageKey): storageKey is string =>
Boolean(storageKey),
),
),
].map((storageKey) => deleteObject(storageKey)),
);
await ctx.db.transaction(async (tx) => {
+12
View File
@@ -320,8 +320,20 @@ export const businesses = createTable(
website: d.varchar({ length: 255 }),
taxId: d.varchar({ length: 100 }),
logoUrl: d.varchar({ length: 500 }),
// Brand assets: the original logo columns are the combined/light variant
// for backwards compatibility with existing uploads.
logoStorageKey: d.varchar({ length: 500 }),
logoMimeType: d.varchar({ length: 100 }),
logoDarkStorageKey: d.varchar({ length: 500 }),
logoDarkMimeType: d.varchar({ length: 100 }),
wordmarkLightStorageKey: d.varchar({ length: 500 }),
wordmarkLightMimeType: d.varchar({ length: 100 }),
wordmarkDarkStorageKey: d.varchar({ length: 500 }),
wordmarkDarkMimeType: d.varchar({ length: 100 }),
iconLightStorageKey: d.varchar({ length: 500 }),
iconLightMimeType: d.varchar({ length: 100 }),
iconDarkStorageKey: d.varchar({ length: 500 }),
iconDarkMimeType: d.varchar({ length: 100 }),
hideNameWithLogo: d.boolean().default(false).notNull(),
isDefault: d.boolean().default(false),
// Email configuration for custom Resend setup