feat: add business brand assets and health checks
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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"} />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -23,6 +23,7 @@ export function proxy(request: NextRequest) {
|
||||
"/api/mcp",
|
||||
"/api/i",
|
||||
"/api/business-logo",
|
||||
"/api/health",
|
||||
];
|
||||
|
||||
// Allow API routes to pass through
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user