feat: add business brand assets and health checks
This commit is contained in:
@@ -76,6 +76,8 @@ RUN chmod -R a+rX apps/web/drizzle apps/web/public apps/web/src/server/db/migrat
|
|||||||
USER bun
|
USER bun
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
WORKDIR /app/apps/web
|
WORKDIR /app/apps/web
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||||
|
CMD bun -e 'const port = process.env.PORT || "3000"; const response = await fetch("http://127.0.0.1:" + port + "/api/health"); if (!response.ok) process.exit(1)'
|
||||||
CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"]
|
CMD ["sh", "-c", "bun src/server/db/migrate.ts && bun run start"]
|
||||||
|
|
||||||
FROM base AS worker
|
FROM base AS worker
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
Alert,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
|
|
||||||
import { AppBackground } from "@/components/AppBackground";
|
import { AppBackground } from "@/components/AppBackground";
|
||||||
import { FilterChip } from "@/components/FilterChip";
|
import { FilterChip } from "@/components/FilterChip";
|
||||||
@@ -24,6 +18,10 @@ import { formatCurrency } from "@/lib/format";
|
|||||||
import type { ThemeColors } from "@/lib/theme-palette";
|
import type { ThemeColors } from "@/lib/theme-palette";
|
||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
import {
|
||||||
|
BusinessBrandImage,
|
||||||
|
hasMobileBusinessBrandAsset,
|
||||||
|
} from "@/components/businesses/BusinessBrandImage";
|
||||||
|
|
||||||
type EntityTab = "clients" | "businesses";
|
type EntityTab = "clients" | "businesses";
|
||||||
|
|
||||||
@@ -48,7 +46,8 @@ export default function EntitiesScreen() {
|
|||||||
|
|
||||||
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
const activeQuery = tab === "clients" ? clientsQuery : businessesQuery;
|
||||||
const isLoading =
|
const isLoading =
|
||||||
clientsQuery.isLoading || (tab === "businesses" && businessesQuery.isLoading);
|
clientsQuery.isLoading ||
|
||||||
|
(tab === "businesses" && businessesQuery.isLoading);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingScreen message="Loading…" />;
|
return <LoadingScreen message="Loading…" />;
|
||||||
@@ -71,11 +70,16 @@ export default function EntitiesScreen() {
|
|||||||
const businesses = businessesQuery.data ?? [];
|
const businesses = businessesQuery.data ?? [];
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
return tab === "clients" ? clientsQuery.refetch() : businessesQuery.refetch();
|
return tab === "clients"
|
||||||
|
? clientsQuery.refetch()
|
||||||
|
: businessesQuery.refetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(id: string, name: string) {
|
function confirmDelete(id: string, name: string) {
|
||||||
Alert.alert(`Delete ${tab === "clients" ? "client" : "business"}?`, `Remove ${name}?`, [
|
Alert.alert(
|
||||||
|
`Delete ${tab === "clients" ? "client" : "business"}?`,
|
||||||
|
`Remove ${name}?`,
|
||||||
|
[
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: "Cancel", style: "cancel" },
|
||||||
{
|
{
|
||||||
text: "Delete",
|
text: "Delete",
|
||||||
@@ -85,7 +89,8 @@ export default function EntitiesScreen() {
|
|||||||
else deleteBusiness.mutate({ id });
|
else deleteBusiness.mutate({ id });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -99,10 +104,7 @@ export default function EntitiesScreen() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<PullToRefresh
|
<PullToRefresh onRefresh={refresh} tintColor={colors.primary} />
|
||||||
onRefresh={refresh}
|
|
||||||
tintColor={colors.primary}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -141,7 +143,10 @@ export default function EntitiesScreen() {
|
|||||||
icon: "create-outline",
|
icon: "create-outline",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
onPress: () => router.push(`/(app)/entities/clients/edit/${client.id}`),
|
onPress: () =>
|
||||||
|
router.push(
|
||||||
|
`/(app)/entities/clients/edit/${client.id}`,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
@@ -152,7 +157,9 @@ export default function EntitiesScreen() {
|
|||||||
onPress: () => confirmDelete(client.id, client.name),
|
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}>
|
<GlassSurface style={styles.card}>
|
||||||
<View style={styles.cardInner}>
|
<View style={styles.cardInner}>
|
||||||
@@ -162,7 +169,10 @@ export default function EntitiesScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
{client.defaultHourlyRate != null ? (
|
{client.defaultHourlyRate != null ? (
|
||||||
<Text style={styles.meta}>
|
<Text style={styles.meta}>
|
||||||
{formatCurrency(client.defaultHourlyRate, client.currency ?? "USD")}
|
{formatCurrency(
|
||||||
|
client.defaultHourlyRate,
|
||||||
|
client.currency ?? "USD",
|
||||||
|
)}
|
||||||
/hr
|
/hr
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -190,7 +200,10 @@ export default function EntitiesScreen() {
|
|||||||
icon: "create-outline",
|
icon: "create-outline",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
backgroundColor: colors.primary,
|
backgroundColor: colors.primary,
|
||||||
onPress: () => router.push(`/(app)/entities/businesses/edit/${business.id}`),
|
onPress: () =>
|
||||||
|
router.push(
|
||||||
|
`/(app)/entities/businesses/edit/${business.id}`,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
@@ -201,10 +214,21 @@ export default function EntitiesScreen() {
|
|||||||
onPress: () => confirmDelete(business.id, business.name),
|
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}>
|
<GlassSurface style={styles.card}>
|
||||||
<View style={styles.cardInner}>
|
<View style={styles.cardInner}>
|
||||||
|
<View style={styles.businessRow}>
|
||||||
|
{hasMobileBusinessBrandAsset(business) ? (
|
||||||
|
<BusinessBrandImage
|
||||||
|
business={business}
|
||||||
|
kind="icon"
|
||||||
|
style={styles.businessIcon}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.businessCopy}>
|
||||||
<View style={styles.nameRow}>
|
<View style={styles.nameRow}>
|
||||||
<Text style={styles.name}>{business.name}</Text>
|
<Text style={styles.name}>{business.name}</Text>
|
||||||
{business.isDefault ? (
|
{business.isDefault ? (
|
||||||
@@ -214,7 +238,11 @@ export default function EntitiesScreen() {
|
|||||||
{business.nickname ? (
|
{business.nickname ? (
|
||||||
<Text style={styles.meta}>{business.nickname}</Text>
|
<Text style={styles.meta}>{business.nickname}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{business.email ? <Text style={styles.meta}>{business.email}</Text> : null}
|
{business.email ? (
|
||||||
|
<Text style={styles.meta}>{business.email}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
</SwipeableRow>
|
</SwipeableRow>
|
||||||
@@ -257,6 +285,21 @@ const createEntitiesStyles = (colors: ThemeColors, isDark: boolean) =>
|
|||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
flexWrap: "wrap",
|
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: {
|
name: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontFamily: fonts.bodySemiBold,
|
fontFamily: fonts.bodySemiBold,
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import { Logo } from "@/components/Logo";
|
|||||||
import { fonts, radii, spacing } from "@/constants/theme";
|
import { fonts, radii, spacing } from "@/constants/theme";
|
||||||
import { useAppTheme } from "@/contexts/ThemeContext";
|
import { useAppTheme } from "@/contexts/ThemeContext";
|
||||||
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
|
import { TOP_CHROME_ROW_HEIGHT } from "@/lib/top-chrome-insets";
|
||||||
|
import {
|
||||||
|
BusinessBrandImage,
|
||||||
|
hasMobileBusinessBrandAsset,
|
||||||
|
} from "@/components/businesses/BusinessBrandImage";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
type TopChromeProps = {
|
type TopChromeProps = {
|
||||||
showMoreBack?: boolean;
|
showMoreBack?: boolean;
|
||||||
@@ -16,6 +21,7 @@ type TopChromeProps = {
|
|||||||
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
|
/** Wordmark left, account switcher right — sits on TopChromeBar blur. */
|
||||||
export function TopChrome({ showMoreBack = false }: TopChromeProps) {
|
export function TopChrome({ showMoreBack = false }: TopChromeProps) {
|
||||||
const { colors, isDark } = useAppTheme();
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const defaultBusiness = api.businesses.getDefault.useQuery();
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
if (router.canGoBack()) {
|
if (router.canGoBack()) {
|
||||||
@@ -34,13 +40,25 @@ export function TopChrome({ showMoreBack = false }: TopChromeProps) {
|
|||||||
onPress={handleBack}
|
onPress={handleBack}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.backButton,
|
styles.backButton,
|
||||||
{ borderColor: colors.borderGlass, backgroundColor: colors.cardGlass },
|
{
|
||||||
|
borderColor: colors.borderGlass,
|
||||||
|
backgroundColor: colors.cardGlass,
|
||||||
|
},
|
||||||
pressed && styles.pressed,
|
pressed && styles.pressed,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={18} color={colors.foreground} />
|
<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>
|
</Pressable>
|
||||||
|
) : defaultBusiness.data &&
|
||||||
|
hasMobileBusinessBrandAsset(defaultBusiness.data) ? (
|
||||||
|
<BusinessBrandImage
|
||||||
|
business={defaultBusiness.data}
|
||||||
|
kind="wordmark"
|
||||||
|
style={styles.businessWordmark}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Logo size="xs" onDark={isDark} />
|
<Logo size="xs" onDark={isDark} />
|
||||||
)}
|
)}
|
||||||
@@ -66,6 +84,10 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: radii.pill,
|
borderRadius: radii.pill,
|
||||||
},
|
},
|
||||||
|
businessWordmark: {
|
||||||
|
width: 132,
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
backLabel: {
|
backLabel: {
|
||||||
fontFamily: fonts.bodySemiBold,
|
fontFamily: fonts.bodySemiBold,
|
||||||
fontSize: 13,
|
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 { useEffect, useState } from "react";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
@@ -19,6 +20,12 @@ import type { ThemeColors } from "@/lib/theme-palette";
|
|||||||
import { useThemedStyles } from "@/lib/use-themed-styles";
|
import { useThemedStyles } from "@/lib/use-themed-styles";
|
||||||
import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
|
import { isRequiredString, useFieldVisibility } from "@/lib/form-validation";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
import { BusinessBrandImage } from "@/components/businesses/BusinessBrandImage";
|
||||||
|
import {
|
||||||
|
getBrandAssetFieldNames,
|
||||||
|
type BrandAssetKind,
|
||||||
|
type BrandAssetTheme,
|
||||||
|
} from "@beenvoice/domain/brand-assets";
|
||||||
|
|
||||||
type BusinessFormValues = {
|
type BusinessFormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -33,6 +40,7 @@ type BusinessFormValues = {
|
|||||||
country: string;
|
country: string;
|
||||||
website: string;
|
website: string;
|
||||||
taxId: string;
|
taxId: string;
|
||||||
|
hideNameWithLogo: boolean;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,9 +57,24 @@ const emptyValues: BusinessFormValues = {
|
|||||||
country: "United States",
|
country: "United States",
|
||||||
website: "",
|
website: "",
|
||||||
taxId: "",
|
taxId: "",
|
||||||
|
hideNameWithLogo: false,
|
||||||
isDefault: 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 = {
|
type BusinessFormProps = {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
businessId?: string;
|
businessId?: string;
|
||||||
@@ -78,6 +101,7 @@ export function BusinessForm({
|
|||||||
|
|
||||||
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
|
const [values, setValues] = useState<BusinessFormValues>(emptyValues);
|
||||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||||
|
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
|
||||||
const { touch, visible, markSubmitted } = useFieldVisibility();
|
const { touch, visible, markSubmitted } = useFieldVisibility();
|
||||||
|
|
||||||
const switchProps = {
|
const switchProps = {
|
||||||
@@ -102,6 +126,7 @@ export function BusinessForm({
|
|||||||
country: business.country ?? "United States",
|
country: business.country ?? "United States",
|
||||||
website: business.website ?? "",
|
website: business.website ?? "",
|
||||||
taxId: business.taxId ?? "",
|
taxId: business.taxId ?? "",
|
||||||
|
hideNameWithLogo: business.hideNameWithLogo ?? false,
|
||||||
isDefault: business.isDefault ?? false,
|
isDefault: business.isDefault ?? false,
|
||||||
});
|
});
|
||||||
}, [businessQuery.data]);
|
}, [businessQuery.data]);
|
||||||
@@ -117,7 +142,8 @@ export function BusinessForm({
|
|||||||
const updateBusiness = api.businesses.update.useMutation({
|
const updateBusiness = api.businesses.update.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void utils.businesses.getAll.invalidate();
|
void utils.businesses.getAll.invalidate();
|
||||||
if (businessId) void utils.businesses.getById.invalidate({ id: businessId });
|
if (businessId)
|
||||||
|
void utils.businesses.getById.invalidate({ id: businessId });
|
||||||
onSaved();
|
onSaved();
|
||||||
},
|
},
|
||||||
onError: (err) => setFieldError(err.message),
|
onError: (err) => setFieldError(err.message),
|
||||||
@@ -130,8 +156,68 @@ export function BusinessForm({
|
|||||||
},
|
},
|
||||||
onError: (err) => Alert.alert("Could not delete business", err.message),
|
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 }));
|
setValues((prev) => ({ ...prev, [field]: value }));
|
||||||
setFieldError(null);
|
setFieldError(null);
|
||||||
}
|
}
|
||||||
@@ -150,6 +236,7 @@ export function BusinessForm({
|
|||||||
country: values.country.trim() || "United States",
|
country: values.country.trim() || "United States",
|
||||||
website: values.website.trim(),
|
website: values.website.trim(),
|
||||||
taxId: values.taxId.trim(),
|
taxId: values.taxId.trim(),
|
||||||
|
hideNameWithLogo: values.hideNameWithLogo,
|
||||||
isDefault: values.isDefault,
|
isDefault: values.isDefault,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -186,7 +273,9 @@ export function BusinessForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saving = createBusiness.isPending || updateBusiness.isPending;
|
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);
|
const canSave = isRequiredString(values.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -195,8 +284,13 @@ export function BusinessForm({
|
|||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={[styles.container, { paddingBottom: scrollPadding }]}
|
contentContainerStyle={[
|
||||||
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : undefined}
|
styles.container,
|
||||||
|
{ paddingBottom: scrollPadding },
|
||||||
|
]}
|
||||||
|
contentInsetAdjustmentBehavior={
|
||||||
|
Platform.OS === "ios" ? "automatic" : undefined
|
||||||
|
}
|
||||||
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
scrollIndicatorInsets={{ bottom: scrollPadding }}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
>
|
||||||
@@ -247,7 +341,9 @@ export function BusinessForm({
|
|||||||
<Text style={[styles.switchLabel, { color: colors.foreground }]}>
|
<Text style={[styles.switchLabel, { color: colors.foreground }]}>
|
||||||
Default business
|
Default business
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.switchHint, { color: colors.mutedForeground }]}>
|
<Text
|
||||||
|
style={[styles.switchHint, { color: colors.mutedForeground }]}
|
||||||
|
>
|
||||||
Used for new invoices when none is selected
|
Used for new invoices when none is selected
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -259,6 +355,108 @@ export function BusinessForm({
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</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">
|
<Card title="Address">
|
||||||
<Input
|
<Input
|
||||||
label="Address line 1"
|
label="Address line 1"
|
||||||
@@ -270,8 +468,16 @@ export function BusinessForm({
|
|||||||
value={values.addressLine2}
|
value={values.addressLine2}
|
||||||
onChangeText={(v) => patch("addressLine2", v)}
|
onChangeText={(v) => patch("addressLine2", v)}
|
||||||
/>
|
/>
|
||||||
<Input label="City" value={values.city} onChangeText={(v) => patch("city", v)} />
|
<Input
|
||||||
<Input label="State" value={values.state} onChangeText={(v) => patch("state", v)} />
|
label="City"
|
||||||
|
value={values.city}
|
||||||
|
onChangeText={(v) => patch("city", v)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="State"
|
||||||
|
value={values.state}
|
||||||
|
onChangeText={(v) => patch("state", v)}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Postal code"
|
label="Postal code"
|
||||||
value={values.postalCode}
|
value={values.postalCode}
|
||||||
@@ -284,7 +490,11 @@ export function BusinessForm({
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{fieldError ? <Text selectable style={styles.error}>{fieldError}</Text> : null}
|
{fieldError ? (
|
||||||
|
<Text selectable style={styles.error}>
|
||||||
|
{fieldError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
@@ -337,6 +547,38 @@ const createBusinessFormStyles = (colors: ThemeColors, _isDark: boolean) =>
|
|||||||
actions: {
|
actions: {
|
||||||
gap: spacing.sm,
|
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: {
|
error: {
|
||||||
color: colors.destructive,
|
color: colors.destructive,
|
||||||
fontFamily: fonts.body,
|
fontFamily: fonts.body,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test";
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
EXPENSE_CATEGORIES as domainExpenseCategories,
|
EXPENSE_CATEGORIES as domainExpenseCategories,
|
||||||
|
addCalendarDays,
|
||||||
|
calendarDateFromInstant,
|
||||||
formatElapsedSeconds as formatDomainElapsedSeconds,
|
formatElapsedSeconds as formatDomainElapsedSeconds,
|
||||||
getEffectiveInvoiceStatus,
|
getEffectiveInvoiceStatus,
|
||||||
} from "@beenvoice/domain";
|
} from "@beenvoice/domain";
|
||||||
@@ -14,9 +16,7 @@ import { generateInvoiceNumber as generateMobileInvoiceNumber } from "../lib/inv
|
|||||||
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
|
import { formatElapsedSeconds as formatAppElapsedSeconds } from "../lib/time-clock";
|
||||||
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
|
import { generateInvoiceNumber as generateWebInvoiceNumber } from "../../web/src/lib/draft-invoice";
|
||||||
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
|
import { safeCallbackPath } from "../../web/src/lib/safe-callback-url";
|
||||||
import {
|
import { normalizeOptionalId } from "../../web/src/lib/time-clock";
|
||||||
normalizeOptionalId,
|
|
||||||
} from "../../web/src/lib/time-clock";
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@@ -52,15 +52,18 @@ describe("invoice parity", () => {
|
|||||||
test("web and mobile use the device-local date in invoice numbers", () => {
|
test("web and mobile use the device-local date in invoice numbers", () => {
|
||||||
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
|
const lateLocalEvening = new Date(2026, 7, 16, 23, 30, 0, 123);
|
||||||
|
|
||||||
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
|
expect(generateMobileInvoiceNumber(lateLocalEvening)).toStartWith(
|
||||||
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith("INV-20260816-");
|
"INV-20260816-",
|
||||||
|
);
|
||||||
|
expect(generateWebInvoiceNumber(lateLocalEvening)).toStartWith(
|
||||||
|
"INV-20260816-",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("web and mobile agree on draft, paid, sent, and overdue states", () => {
|
test("web and mobile agree on draft, paid, sent, and overdue states", () => {
|
||||||
const today = new Date();
|
const timeZone = "America/New_York";
|
||||||
today.setHours(0, 0, 0, 0);
|
const today = calendarDateFromInstant(new Date(), timeZone);
|
||||||
const yesterday = new Date(today);
|
const yesterday = addCalendarDays(today, -1);
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
|
|
||||||
const fixtures = [
|
const fixtures = [
|
||||||
{
|
{
|
||||||
@@ -82,11 +85,15 @@ describe("invoice parity", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const fixture of fixtures) {
|
for (const fixture of fixtures) {
|
||||||
expect(getEffectiveInvoiceStatus(fixture.stored, fixture.dueDate)).toBe(
|
|
||||||
fixture.expected,
|
|
||||||
);
|
|
||||||
expect(
|
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);
|
).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,
|
"when": 1786950000000,
|
||||||
"tag": "0031_timezone_safety",
|
"tag": "0031_timezone_safety",
|
||||||
"breakpoints": true
|
"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 { getObject } from "~/lib/object-storage";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { businesses } from "~/server/db/schema";
|
import { businesses } from "~/server/db/schema";
|
||||||
|
import {
|
||||||
|
brandAssetKinds,
|
||||||
|
brandAssetThemes,
|
||||||
|
resolveBusinessBrandAsset,
|
||||||
|
type BrandAssetKind,
|
||||||
|
type BrandAssetTheme,
|
||||||
|
} from "~/lib/business-branding";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -16,27 +23,57 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ businessId: string }> },
|
{ params }: { params: Promise<{ businessId: string }> },
|
||||||
) {
|
) {
|
||||||
const { businessId } = await params;
|
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({
|
const business = await db.query.businesses.findFirst({
|
||||||
where: eq(businesses.id, businessId),
|
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 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
|
// @react-pdf/renderer's Image component only decodes PNG/JPEG, so PDF
|
||||||
// generation requests a rasterized copy of SVG/WebP logos via this param.
|
// generation requests a rasterized copy of SVG/WebP logos via this param.
|
||||||
const wantsPng =
|
const wantsPng =
|
||||||
new URL(req.url).searchParams.get("format") === "png" &&
|
url.searchParams.get("format") === "png" &&
|
||||||
RASTERIZABLE_MIME_TYPES.has(business.logoMimeType);
|
RASTERIZABLE_MIME_TYPES.has(asset.mimeType);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await getObject(business.logoStorageKey);
|
const body = await getObject(asset.storageKey);
|
||||||
|
|
||||||
if (wantsPng) {
|
if (wantsPng) {
|
||||||
const { default: sharp } = await import("sharp");
|
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
|
// 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).
|
// the size it's actually displayed (PDF header, up to ~2.2in wide).
|
||||||
// withoutEnlargement only makes sense for the WebP (already-raster)
|
// withoutEnlargement only makes sense for the WebP (already-raster)
|
||||||
@@ -62,7 +99,7 @@ export async function GET(
|
|||||||
|
|
||||||
return new NextResponse(new Uint8Array(body), {
|
return new NextResponse(new Uint8Array(body), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": business.logoMimeType,
|
"Content-Type": asset.mimeType,
|
||||||
"Cache-Control": "public, max-age=300, must-revalidate",
|
"Cache-Control": "public, max-age=300, must-revalidate",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
},
|
},
|
||||||
@@ -71,6 +108,8 @@ export async function GET(
|
|||||||
console.error("[business-logo] Failed to serve logo", {
|
console.error("[business-logo] Failed to serve logo", {
|
||||||
backendError: error,
|
backendError: error,
|
||||||
businessId,
|
businessId,
|
||||||
|
kind,
|
||||||
|
theme,
|
||||||
wantsPng,
|
wantsPng,
|
||||||
});
|
});
|
||||||
return NextResponse.json({ error: "Logo not found" }, { status: 404 });
|
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,
|
taxId: true,
|
||||||
logoStorageKey: true,
|
logoStorageKey: true,
|
||||||
logoMimeType: 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,
|
hideNameWithLogo: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -52,8 +62,14 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoice.publicTokenExpiresAt && new Date(invoice.publicTokenExpiresAt) < new Date()) {
|
if (
|
||||||
return NextResponse.json({ error: "This link has expired" }, { status: 410 });
|
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({
|
const settings = await db.query.platformSettings.findFirst({
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
|
||||||
|
import { hasBusinessBrandAsset } from "~/lib/business-branding";
|
||||||
|
|
||||||
interface BusinessDetailPageProps {
|
interface BusinessDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -74,13 +76,12 @@ export default async function BusinessDetailPage({
|
|||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<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">
|
<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 */}
|
<BusinessBrandImage
|
||||||
<img
|
business={business}
|
||||||
src={`/api/business-logo/${business.id}`}
|
kind="icon"
|
||||||
alt={`${business.name} logo`}
|
className="h-full w-full"
|
||||||
className="h-full w-auto max-w-full object-contain"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
} from "~/components/ui/dialog";
|
} from "~/components/ui/dialog";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
|
||||||
|
import { hasBusinessBrandAsset } from "~/lib/business-branding";
|
||||||
|
|
||||||
// Type for business data
|
// Type for business data
|
||||||
interface Business {
|
interface Business {
|
||||||
@@ -35,6 +37,17 @@ interface Business {
|
|||||||
taxId: string | null;
|
taxId: string | null;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
logoStorageKey: 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;
|
createdById: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date | null;
|
updatedAt: Date | null;
|
||||||
@@ -88,12 +101,12 @@ export function BusinessesDataTable({ businesses }: BusinessesDataTableProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<div className="bg-primary/10 hidden h-8 w-8 shrink-0 items-center justify-center overflow-hidden p-2 sm:flex">
|
||||||
{business.logoStorageKey ? (
|
{hasBusinessBrandAsset(business) ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- object-storage-backed image, not a static asset
|
<BusinessBrandImage
|
||||||
<img
|
business={business}
|
||||||
src={`/api/business-logo/${business.id}`}
|
kind="icon"
|
||||||
alt=""
|
decorative
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Building className="text-primary h-4 w-4" />
|
<Building className="text-primary h-4 w-4" />
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
toZonedDateTimeInputValue,
|
toZonedDateTimeInputValue,
|
||||||
zonedDateTimeToInstant,
|
zonedDateTimeToInstant,
|
||||||
} from "@beenvoice/domain/time-zone";
|
} 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 Link from "next/link";
|
||||||
import {
|
import {
|
||||||
notFound,
|
notFound,
|
||||||
@@ -424,13 +426,12 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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">
|
<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 */}
|
<BusinessBrandImage
|
||||||
<img
|
business={invoice.business}
|
||||||
src={`/api/business-logo/${invoice.business.id}`}
|
kind="logo"
|
||||||
alt={`${invoice.business.name} logo`}
|
className="h-full max-w-36 min-w-20"
|
||||||
className="h-full w-auto max-w-full object-contain"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -264,6 +264,19 @@ export default function SendEmailPage() {
|
|||||||
email: invoiceData.business.email,
|
email: invoiceData.business.email,
|
||||||
logoStorageKey: invoiceData.business.logoStorageKey,
|
logoStorageKey: invoiceData.business.logoStorageKey,
|
||||||
logoMimeType: invoiceData.business.logoMimeType,
|
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,
|
: undefined,
|
||||||
items: invoiceData.items?.map((item) => ({
|
items: invoiceData.items?.map((item) => ({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
formatCalendarDate,
|
formatCalendarDate,
|
||||||
getEffectiveInvoiceStatus,
|
getEffectiveInvoiceStatus,
|
||||||
} from "@beenvoice/domain";
|
} from "@beenvoice/domain";
|
||||||
|
import { BusinessBrandImage } from "~/components/branding/business-brand-image";
|
||||||
|
import { hasBusinessBrandAsset } from "~/lib/business-branding";
|
||||||
|
|
||||||
function formatDate(date: Date) {
|
function formatDate(date: Date) {
|
||||||
return formatCalendarDate(date, {
|
return formatCalendarDate(date, {
|
||||||
@@ -121,7 +123,7 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||||
: invoice.business.name
|
: invoice.business.name
|
||||||
: null;
|
: null;
|
||||||
const hasLogo = Boolean(invoice.business?.logoStorageKey);
|
const hasLogo = hasBusinessBrandAsset(invoice.business);
|
||||||
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
|
const hideName = hasLogo && Boolean(invoice.business?.hideNameWithLogo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,13 +134,12 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
|
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
|
||||||
{hasLogo && (
|
{hasLogo && (
|
||||||
// Uploaded SVGs are sanitized and served by our route. next/image's
|
<BusinessBrandImage
|
||||||
// optimizer intentionally rejects SVG, so a native img is required.
|
business={invoice.business!}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
kind="logo"
|
||||||
<img
|
theme="dark"
|
||||||
src={`/api/business-logo/${invoice.business!.id}`}
|
decorative
|
||||||
alt=""
|
className="h-16 w-[220px] max-w-[42%] shrink-0 rounded px-2 py-1.5"
|
||||||
className="h-16 w-auto max-w-[220px] shrink-0 rounded bg-white object-contain px-2 py-1.5"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<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 { AddressForm } from "~/components/forms/address-form";
|
||||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||||
import { DashboardPageHeader } from "~/components/layout/page-header";
|
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 { cn } from "~/lib/utils";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
@@ -43,6 +46,13 @@ import {
|
|||||||
VALIDATION_MESSAGES,
|
VALIDATION_MESSAGES,
|
||||||
} from "~/lib/form-constants";
|
} from "~/lib/form-constants";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
|
import {
|
||||||
|
businessBrandAssetPath,
|
||||||
|
getBrandAssetFieldNames,
|
||||||
|
hasBusinessBrandAsset,
|
||||||
|
type BrandAssetKind,
|
||||||
|
type BrandAssetTheme,
|
||||||
|
} from "~/lib/business-branding";
|
||||||
|
|
||||||
interface BusinessFormProps {
|
interface BusinessFormProps {
|
||||||
businessId?: string;
|
businessId?: string;
|
||||||
@@ -114,6 +124,54 @@ const ACCEPTED_LOGO_TYPES = new Set([
|
|||||||
"image/svg+xml",
|
"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) {
|
export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -123,7 +181,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
const [showApiKey, setShowApiKey] = useState(false);
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
const [uploadingAsset, setUploadingAsset] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch business data if editing
|
// Fetch business data if editing
|
||||||
const { data: business, isLoading: isLoadingBusiness } =
|
const { data: business, isLoading: isLoadingBusiness } =
|
||||||
@@ -165,20 +223,20 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const uploadLogo = api.businesses.uploadLogo.useMutation({
|
const uploadLogo = api.businesses.uploadLogo.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async (_data, variables) => {
|
||||||
await utils.businesses.getById.invalidate({ id: businessId });
|
await utils.businesses.getById.invalidate({ id: businessId });
|
||||||
toast.success("Logo updated");
|
toast.success(`${variables.kind} ${variables.theme} variant updated`);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || "Failed to upload logo");
|
toast.error(error.message || "Failed to upload logo");
|
||||||
},
|
},
|
||||||
onSettled: () => setIsUploadingLogo(false),
|
onSettled: () => setUploadingAsset(null),
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeLogo = api.businesses.removeLogo.useMutation({
|
const removeLogo = api.businesses.removeLogo.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async (_data, variables) => {
|
||||||
await utils.businesses.getById.invalidate({ id: businessId });
|
await utils.businesses.getById.invalidate({ id: businessId });
|
||||||
toast.success("Logo removed");
|
toast.success(`${variables.kind} ${variables.theme} variant removed`);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || "Failed to remove logo");
|
toast.error(error.message || "Failed to remove logo");
|
||||||
@@ -187,6 +245,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
|
|
||||||
const handleLogoFileSelected = async (
|
const handleLogoFileSelected = async (
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
kind: BrandAssetKind,
|
||||||
|
theme: BrandAssetTheme,
|
||||||
) => {
|
) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
@@ -201,7 +261,7 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUploadingLogo(true);
|
setUploadingAsset(assetSlotKey(kind, theme));
|
||||||
const data = await new Promise<string>((resolve, reject) => {
|
const data = await new Promise<string>((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
@@ -217,6 +277,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
data,
|
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)
|
// Load business data once when editing (avoid overwriting unsaved changes on refetch)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (business && mode === "edit" && !initialized && !isLoadingEmailConfig) {
|
||||||
business &&
|
|
||||||
mode === "edit" &&
|
|
||||||
!initialized &&
|
|
||||||
!isLoadingEmailConfig
|
|
||||||
) {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync loaded business data into the edit form.
|
||||||
setFormData({
|
setFormData({
|
||||||
name: business.name,
|
name: business.name,
|
||||||
@@ -732,74 +789,128 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
<ImageIcon className="text-muted-foreground h-5 w-5" />
|
<ImageIcon className="text-muted-foreground h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Logo</CardTitle>
|
<CardTitle>Brand assets</CardTitle>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
Shown on invoices sent to your clients. PNG, JPEG,
|
Add logos, wordmarks, and icons for light and dark
|
||||||
WebP, or SVG, up to 5MB.
|
backgrounds. Missing variants fall back automatically.
|
||||||
|
PNG, JPEG, WebP, or SVG, up to 5MB.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<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">
|
{BRAND_ASSET_SLOTS.map((slot) => {
|
||||||
{business?.logoStorageKey ? (
|
const [storageField] = getBrandAssetFieldNames(
|
||||||
// eslint-disable-next-line @next/next/no-img-element -- external/object-storage-backed image, not a static asset
|
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"
|
||||||
|
>
|
||||||
|
<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
|
<img
|
||||||
src={`/api/business-logo/${businessId}?v=${business.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
|
src={`${businessBrandAssetPath(businessId, slot.kind, slot.theme)}&v=${business?.updatedAt ? new Date(business.updatedAt).getTime() : 0}`}
|
||||||
alt={`${business.name} logo`}
|
alt={`${business?.name ?? "Business"} ${slot.label}`}
|
||||||
className="h-full w-auto max-w-full object-contain"
|
className={cn(
|
||||||
|
"max-h-full max-w-full object-contain",
|
||||||
|
slot.kind === "icon" && "aspect-square",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="text-muted-foreground/50 h-8 w-8" />
|
<ImageIcon
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8",
|
||||||
|
slot.theme === "dark"
|
||||||
|
? "text-white/35"
|
||||||
|
: "text-black/25",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={isUploadingLogo}
|
className="flex-1"
|
||||||
|
disabled={Boolean(uploadingAsset)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
document.getElementById("logo-upload-input")?.click()
|
document.getElementById(inputId)?.click()
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isUploadingLogo ? (
|
{isUploading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin sm:mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Upload className="h-4 w-4 sm:mr-2" />
|
<Upload className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span className="hidden sm:inline">
|
{hasAsset ? "Replace" : "Upload"}
|
||||||
{business?.logoStorageKey
|
|
||||||
? "Replace logo"
|
|
||||||
: "Upload logo"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
{business?.logoStorageKey && (
|
{hasAsset ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
aria-label={`Remove ${slot.label}`}
|
||||||
disabled={removeLogo.isPending}
|
disabled={removeLogo.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
businessId && removeLogo.mutate({ id: businessId })
|
businessId &&
|
||||||
|
removeLogo.mutate({
|
||||||
|
id: businessId,
|
||||||
|
kind: slot.kind,
|
||||||
|
theme: slot.theme,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 sm:mr-2" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Remove</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
<input
|
<input
|
||||||
id="logo-upload-input"
|
id={inputId}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleLogoFileSelected}
|
onChange={(event) =>
|
||||||
|
void handleLogoFileSelected(
|
||||||
|
event,
|
||||||
|
slot.kind,
|
||||||
|
slot.theme,
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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="bg-muted border-border/40 mt-4 flex items-center justify-between border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label
|
<Label
|
||||||
@@ -809,8 +920,8 @@ export function BusinessForm({ businessId, mode }: BusinessFormProps) {
|
|||||||
Hide business name on invoices
|
Hide business name on invoices
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Show only the logo in the invoice header — useful
|
Show only the logo in the invoice header — useful if
|
||||||
if your logo already includes your business name.
|
your logo already includes your business name.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
import { generateInvoiceEmailTemplate } from "~/lib/email-templates";
|
||||||
import { getAppUrl } from "~/lib/app-url";
|
import { getAppUrl } from "~/lib/app-url";
|
||||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||||
|
import type { BusinessBrandAssets } from "~/lib/business-branding";
|
||||||
|
|
||||||
interface EmailPreviewProps {
|
interface EmailPreviewProps {
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -28,9 +29,7 @@ interface EmailPreviewProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
logoStorageKey?: string | null;
|
} & BusinessBrandAssets;
|
||||||
logoMimeType?: string | null;
|
|
||||||
};
|
|
||||||
items?: Array<{
|
items?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
date?: Date;
|
date?: Date;
|
||||||
@@ -87,7 +86,8 @@ export function EmailPreview({
|
|||||||
description: item.description ?? "Service",
|
description: item.description ?? "Service",
|
||||||
hours: item.hours,
|
hours: item.hours,
|
||||||
rate: item.rate,
|
rate: item.rate,
|
||||||
amount: item.amount ?? calculateLineItemAmount(item.hours, item.rate),
|
amount:
|
||||||
|
item.amount ?? calculateLineItemAmount(item.hours, item.rate),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
},
|
},
|
||||||
customContent: content,
|
customContent: content,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "~/components/layout/sidebar-provider";
|
} from "~/components/layout/sidebar-provider";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Menu } from "lucide-react";
|
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 { Button } from "~/components/ui/button";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||||
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
import { ActiveTimerWidget } from "~/app/dashboard/_components/active-timer-widget";
|
||||||
@@ -48,7 +48,7 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<div className="ml-3 flex min-w-0 flex-1 items-center gap-2 sm:ml-4">
|
<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 />
|
<ActiveTimerWidget compact />
|
||||||
</div>
|
</div>
|
||||||
<SheetContent side="left" className="w-72 p-0">
|
<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 { getNavigationForUser, isNavLinkActive } from "~/lib/navigation";
|
||||||
import { useSidebar } from "./sidebar-provider";
|
import { useSidebar } from "./sidebar-provider";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Logo } from "~/components/branding/logo";
|
import { DashboardBrand } from "~/components/branding/dashboard-brand";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -57,10 +57,10 @@ export function Sidebar({ mobile, onClose }: SidebarProps) {
|
|||||||
>
|
>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Logo size="sm" />
|
<DashboardBrand />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{collapsed && <Logo size="icon" />}
|
{collapsed && <DashboardBrand compact />}
|
||||||
|
|
||||||
{!mobile && !collapsed && (
|
{!mobile && !collapsed && (
|
||||||
<div className="h-8 w-8" /> // Spacer to keep alignment if needed, or just remove
|
<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 { getAppUrl } from "~/lib/app-url";
|
||||||
import { formatCalendarDate } from "@beenvoice/domain/time-zone";
|
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
|
// 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
|
// 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.
|
// rasterization the PDF export uses.
|
||||||
function resolveEmailLogoUrl(
|
function resolveEmailLogoUrl(
|
||||||
business:
|
business:
|
||||||
| {
|
| ({
|
||||||
id?: string;
|
id?: string;
|
||||||
logoStorageKey?: string | null;
|
} & BusinessBrandAssets)
|
||||||
logoMimeType?: string | null;
|
|
||||||
}
|
|
||||||
| null
|
| null
|
||||||
| undefined,
|
| undefined,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!business?.id || !business.logoStorageKey) return null;
|
if (!business?.id) return null;
|
||||||
const needsRaster =
|
const asset = resolveBusinessBrandAsset(business, "logo", "light");
|
||||||
business.logoMimeType != null &&
|
if (!asset) return null;
|
||||||
!["image/png", "image/jpeg"].includes(business.logoMimeType);
|
const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
|
||||||
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
|
const path = businessBrandAssetPath(
|
||||||
|
business.id,
|
||||||
|
"logo",
|
||||||
|
"light",
|
||||||
|
needsRaster ? "png" : undefined,
|
||||||
|
);
|
||||||
return `${baseUrl.replace(/\/$/, "")}${path}`;
|
return `${baseUrl.replace(/\/$/, "")}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +45,8 @@ interface InvoiceEmailTemplateProps {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
};
|
};
|
||||||
business?: {
|
business?:
|
||||||
|
| ({
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
@@ -49,9 +58,8 @@ interface InvoiceEmailTemplateProps {
|
|||||||
state?: string | null;
|
state?: string | null;
|
||||||
postalCode?: string | null;
|
postalCode?: string | null;
|
||||||
country?: string | null;
|
country?: string | null;
|
||||||
logoStorageKey?: string | null;
|
} & BusinessBrandAssets)
|
||||||
logoMimeType?: string | null;
|
| null;
|
||||||
} | null;
|
|
||||||
items: Array<{
|
items: Array<{
|
||||||
date: Date;
|
date: Date;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
pdfFontCacheKey,
|
pdfFontCacheKey,
|
||||||
resolvePdfFonts,
|
resolvePdfFonts,
|
||||||
} from "~/lib/pdf-fonts";
|
} from "~/lib/pdf-fonts";
|
||||||
|
import {
|
||||||
|
businessBrandAssetPath,
|
||||||
|
resolveBusinessBrandAsset,
|
||||||
|
type BusinessBrandAssets,
|
||||||
|
} from "~/lib/business-branding";
|
||||||
|
|
||||||
// Fallback download function for better browser compatibility
|
// Fallback download function for better browser compatibility
|
||||||
function downloadBlob(blob: Blob, filename: string): void {
|
function downloadBlob(blob: Blob, filename: string): void {
|
||||||
@@ -73,7 +78,8 @@ export interface InvoiceData {
|
|||||||
taxRate: number;
|
taxRate: number;
|
||||||
currency?: string | null;
|
currency?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
business?: {
|
business?:
|
||||||
|
| ({
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
@@ -87,10 +93,9 @@ export interface InvoiceData {
|
|||||||
country?: string | null;
|
country?: string | null;
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
taxId?: string | null;
|
taxId?: string | null;
|
||||||
logoStorageKey?: string | null;
|
|
||||||
logoMimeType?: string | null;
|
|
||||||
hideNameWithLogo?: boolean | null;
|
hideNameWithLogo?: boolean | null;
|
||||||
} | null;
|
} & BusinessBrandAssets)
|
||||||
|
| null;
|
||||||
client?: {
|
client?: {
|
||||||
name: string;
|
name: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
@@ -848,12 +853,17 @@ function resolveBusinessLogoSrc(
|
|||||||
business: InvoiceData["business"],
|
business: InvoiceData["business"],
|
||||||
baseUrlOverride?: string,
|
baseUrlOverride?: string,
|
||||||
): string | null {
|
): 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 =
|
const needsRaster = !["image/png", "image/jpeg"].includes(asset.mimeType);
|
||||||
business.logoMimeType != null &&
|
const path = businessBrandAssetPath(
|
||||||
!["image/png", "image/jpeg"].includes(business.logoMimeType);
|
business.id,
|
||||||
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
|
"logo",
|
||||||
|
"light",
|
||||||
|
needsRaster ? "png" : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return `${window.location.origin}${path}`;
|
return `${window.location.origin}${path}`;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function proxy(request: NextRequest) {
|
|||||||
"/api/mcp",
|
"/api/mcp",
|
||||||
"/api/i",
|
"/api/i",
|
||||||
"/api/business-logo",
|
"/api/business-logo",
|
||||||
|
"/api/health",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Allow API routes to pass through
|
// Allow API routes to pass through
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { invoices } from "~/server/db/schema";
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { deleteObject, putObject } from "~/lib/object-storage";
|
import { deleteObject, putObject } from "~/lib/object-storage";
|
||||||
import { sanitizeSvg } from "~/lib/svg-sanitize";
|
import { sanitizeSvg } from "~/lib/svg-sanitize";
|
||||||
|
import {
|
||||||
|
brandAssetKinds,
|
||||||
|
brandAssetThemes,
|
||||||
|
getBrandAssetFieldNames,
|
||||||
|
} from "~/lib/business-branding";
|
||||||
|
|
||||||
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
|
const MAX_LOGO_BYTES = 5 * 1024 * 1024;
|
||||||
const allowedLogoMimeTypes = new Set([
|
const allowedLogoMimeTypes = new Set([
|
||||||
@@ -287,6 +292,7 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
"Business not found or you don't have permission to delete it",
|
"Business not found or you don't have permission to delete it",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const existingBusiness = business[0];
|
||||||
|
|
||||||
// Check if this business has any invoices
|
// Check if this business has any invoices
|
||||||
const invoiceCount = await ctx.db
|
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
|
await ctx.db
|
||||||
.delete(businesses)
|
.delete(businesses)
|
||||||
.where(
|
.where(
|
||||||
@@ -309,6 +322,12 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
[...new Set(storageKeys)].map((key) =>
|
||||||
|
deleteObject(key).catch(() => undefined),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return { success: true };
|
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
|
uploadLogo: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -440,6 +460,8 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
filename: z.string().min(1).max(255),
|
filename: z.string().min(1).max(255),
|
||||||
mimeType: z.string().min(1).max(100),
|
mimeType: z.string().min(1).max(100),
|
||||||
data: z.string().min(1),
|
data: z.string().min(1),
|
||||||
|
kind: z.enum(brandAssetKinds).default("logo"),
|
||||||
|
theme: z.enum(brandAssetThemes).default("light"),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -457,7 +479,8 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
if (!business) {
|
if (!business) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
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)) {
|
if (!allowedLogoMimeTypes.has(mimeType)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
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) {
|
if (!body.length || body.length > MAX_LOGO_BYTES) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
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 safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${crypto.randomUUID()}-${safeName}`;
|
const storageKey = `logos/${ctx.session.user.id}/${business.id}/${input.kind}/${input.theme}/${crypto.randomUUID()}-${safeName}`;
|
||||||
const previousStorageKey = business.logoStorageKey;
|
const [storageField, mimeField] = getBrandAssetFieldNames(
|
||||||
|
input.kind,
|
||||||
|
input.theme,
|
||||||
|
);
|
||||||
|
const previousStorageKey = business[storageField];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await putObject(storageKey, body, mimeType);
|
await putObject(storageKey, body, mimeType);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[businesses.uploadLogo] Failed to store logo", {
|
console.error("[businesses.uploadLogo] Failed to store brand asset", {
|
||||||
backendError: error,
|
backendError: error,
|
||||||
businessId: business.id,
|
businessId: business.id,
|
||||||
});
|
});
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message:
|
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,
|
cause: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -503,8 +530,8 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
const [updatedBusiness] = await ctx.db
|
const [updatedBusiness] = await ctx.db
|
||||||
.update(businesses)
|
.update(businesses)
|
||||||
.set({
|
.set({
|
||||||
logoStorageKey: storageKey,
|
[storageField]: storageKey,
|
||||||
logoMimeType: mimeType,
|
[mimeField]: mimeType,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(businesses.id, business.id))
|
.where(eq(businesses.id, business.id))
|
||||||
@@ -517,9 +544,15 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
return updatedBusiness;
|
return updatedBusiness;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Remove a business logo
|
// Remove one business brand asset. Defaults preserve older clients.
|
||||||
removeLogo: protectedProcedure
|
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 }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const [business] = await ctx.db
|
const [business] = await ctx.db
|
||||||
.select()
|
.select()
|
||||||
@@ -535,17 +568,27 @@ export const businessesRouter = createTRPCRouter({
|
|||||||
if (!business) {
|
if (!business) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
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) {
|
const [storageField, mimeField] = getBrandAssetFieldNames(
|
||||||
await deleteObject(business.logoStorageKey).catch(() => undefined);
|
input.kind,
|
||||||
|
input.theme,
|
||||||
|
);
|
||||||
|
const storageKey = business[storageField];
|
||||||
|
if (storageKey) {
|
||||||
|
await deleteObject(storageKey).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updatedBusiness] = await ctx.db
|
const [updatedBusiness] = await ctx.db
|
||||||
.update(businesses)
|
.update(businesses)
|
||||||
.set({ logoStorageKey: null, logoMimeType: null, updatedAt: new Date() })
|
.set({
|
||||||
|
[storageField]: null,
|
||||||
|
[mimeField]: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
.where(eq(businesses.id, business.id))
|
.where(eq(businesses.id, business.id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -1124,6 +1124,16 @@ export const invoicesRouter = createTRPCRouter({
|
|||||||
taxId: true,
|
taxId: true,
|
||||||
logoStorageKey: true,
|
logoStorageKey: true,
|
||||||
logoMimeType: 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,
|
hideNameWithLogo: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1212,14 +1212,21 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx }) => {
|
.mutation(async ({ ctx }) => {
|
||||||
const userId = ctx.session.user.id;
|
const userId = ctx.session.user.id;
|
||||||
|
|
||||||
const [receiptObjects, logoObjects] = await Promise.all([
|
const [receiptObjects, brandObjects] = await Promise.all([
|
||||||
ctx.db
|
ctx.db
|
||||||
.select({ storageKey: expenseReceipts.storageKey })
|
.select({ storageKey: expenseReceipts.storageKey })
|
||||||
.from(expenseReceipts)
|
.from(expenseReceipts)
|
||||||
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
|
.innerJoin(expenses, eq(expenseReceipts.expenseId, expenses.id))
|
||||||
.where(eq(expenses.createdById, userId)),
|
.where(eq(expenses.createdById, userId)),
|
||||||
ctx.db
|
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)
|
.from(businesses)
|
||||||
.where(eq(businesses.createdById, userId)),
|
.where(eq(businesses.createdById, userId)),
|
||||||
]);
|
]);
|
||||||
@@ -1227,9 +1234,16 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
// Delete uploaded personal data before removing its database pointers. If object
|
// Delete uploaded personal data before removing its database pointers. If object
|
||||||
// storage is unavailable, the account remains intact so the user can retry.
|
// storage is unavailable, the account remains intact so the user can retry.
|
||||||
await Promise.all(
|
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) => {
|
await ctx.db.transaction(async (tx) => {
|
||||||
|
|||||||
@@ -320,8 +320,20 @@ export const businesses = createTable(
|
|||||||
website: d.varchar({ length: 255 }),
|
website: d.varchar({ length: 255 }),
|
||||||
taxId: d.varchar({ length: 100 }),
|
taxId: d.varchar({ length: 100 }),
|
||||||
logoUrl: d.varchar({ length: 500 }),
|
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 }),
|
logoStorageKey: d.varchar({ length: 500 }),
|
||||||
logoMimeType: d.varchar({ length: 100 }),
|
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(),
|
hideNameWithLogo: d.boolean().default(false).notNull(),
|
||||||
isDefault: d.boolean().default(false),
|
isDefault: d.boolean().default(false),
|
||||||
// Email configuration for custom Resend setup
|
// Email configuration for custom Resend setup
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ services:
|
|||||||
S3_REGION: ${S3_REGION:-garage}
|
S3_REGION: ${S3_REGION:-garage}
|
||||||
expose:
|
expose:
|
||||||
- "${APP_PORT:-3000}"
|
- "${APP_PORT:-3000}"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"bun",
|
||||||
|
"-e",
|
||||||
|
'const port = process.env.PORT || "3000"; const response = await fetch("http://127.0.0.1:" + port + "/api/health"); if (!response.ok) process.exit(1)',
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -80,7 +92,7 @@ services:
|
|||||||
APP_INTERNAL_URL: http://app:${APP_PORT:-3000}
|
APP_INTERNAL_URL: http://app:${APP_PORT:-3000}
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_started
|
condition: service_healthy
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+13
-1
@@ -50,6 +50,18 @@ services:
|
|||||||
S3_REGION: ${S3_REGION:-garage}
|
S3_REGION: ${S3_REGION:-garage}
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
- "${WEB_PORT:-${PORT:-3000}}:3000"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"bun",
|
||||||
|
"-e",
|
||||||
|
'const port = process.env.PORT || "3000"; const response = await fetch("http://127.0.0.1:" + port + "/api/health"); if (!response.ok) process.exit(1)',
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -76,7 +88,7 @@ services:
|
|||||||
APP_INTERNAL_URL: http://app:${APP_PORT:-3000}
|
APP_INTERNAL_URL: http://app:${APP_PORT:-3000}
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_started
|
condition: service_healthy
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
|
"./brand-assets": "./src/brand-assets.ts",
|
||||||
"./expense-categories": "./src/expense-categories.ts",
|
"./expense-categories": "./src/expense-categories.ts",
|
||||||
"./invoice-status": "./src/invoice-status.ts",
|
"./invoice-status": "./src/invoice-status.ts",
|
||||||
"./receipt-parse": "./src/receipt-parse.ts",
|
"./receipt-parse": "./src/receipt-parse.ts",
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export const brandAssetKinds = ["logo", "wordmark", "icon"] as const;
|
||||||
|
export const brandAssetThemes = ["light", "dark"] as const;
|
||||||
|
|
||||||
|
export type BrandAssetKind = (typeof brandAssetKinds)[number];
|
||||||
|
export type BrandAssetTheme = (typeof brandAssetThemes)[number];
|
||||||
|
|
||||||
|
export type BusinessBrandAssets = {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
logo: {
|
||||||
|
light: ["logoStorageKey", "logoMimeType"],
|
||||||
|
dark: ["logoDarkStorageKey", "logoDarkMimeType"],
|
||||||
|
},
|
||||||
|
wordmark: {
|
||||||
|
light: ["wordmarkLightStorageKey", "wordmarkLightMimeType"],
|
||||||
|
dark: ["wordmarkDarkStorageKey", "wordmarkDarkMimeType"],
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
light: ["iconLightStorageKey", "iconLightMimeType"],
|
||||||
|
dark: ["iconDarkStorageKey", "iconDarkMimeType"],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getBrandAssetFieldNames(
|
||||||
|
kind: BrandAssetKind,
|
||||||
|
theme: BrandAssetTheme,
|
||||||
|
) {
|
||||||
|
return fields[kind][theme];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackOrder(
|
||||||
|
kind: BrandAssetKind,
|
||||||
|
theme: BrandAssetTheme,
|
||||||
|
): Array<readonly [BrandAssetKind, BrandAssetTheme]> {
|
||||||
|
const opposite = theme === "light" ? "dark" : "light";
|
||||||
|
const otherKinds = brandAssetKinds.filter((candidate) => candidate !== kind);
|
||||||
|
return [
|
||||||
|
[kind, theme],
|
||||||
|
[kind, opposite],
|
||||||
|
...otherKinds.map((candidate) => [candidate, theme] as const),
|
||||||
|
...otherKinds.map((candidate) => [candidate, opposite] as const),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBusinessBrandAsset(
|
||||||
|
business: BusinessBrandAssets,
|
||||||
|
kind: BrandAssetKind,
|
||||||
|
theme: BrandAssetTheme,
|
||||||
|
): { storageKey: string; mimeType: string } | null {
|
||||||
|
for (const [candidateKind, candidateTheme] of fallbackOrder(kind, theme)) {
|
||||||
|
const [storageField, mimeField] = fields[candidateKind][candidateTheme];
|
||||||
|
const storageKey = business[storageField];
|
||||||
|
const mimeType = business[mimeField];
|
||||||
|
if (storageKey && mimeType) return { storageKey, mimeType };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBusinessBrandAsset(
|
||||||
|
business: BusinessBrandAssets | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
business &&
|
||||||
|
brandAssetKinds.some((kind) =>
|
||||||
|
brandAssetThemes.some((theme) => {
|
||||||
|
const [storageField] = fields[kind][theme];
|
||||||
|
return Boolean(business[storageField]);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./brand-assets";
|
||||||
export * from "./expense-categories";
|
export * from "./expense-categories";
|
||||||
export * from "./invoice-status";
|
export * from "./invoice-status";
|
||||||
export * from "./receipt-parse";
|
export * from "./receipt-parse";
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasBusinessBrandAsset,
|
||||||
|
resolveBusinessBrandAsset,
|
||||||
|
} from "../src/brand-assets";
|
||||||
|
import { businessBrandAssetPath } from "../../../apps/web/src/lib/business-branding";
|
||||||
|
|
||||||
|
describe("business brand assets", () => {
|
||||||
|
test("prefers the requested kind and theme", () => {
|
||||||
|
const asset = resolveBusinessBrandAsset(
|
||||||
|
{
|
||||||
|
logoStorageKey: "logo-light",
|
||||||
|
logoMimeType: "image/png",
|
||||||
|
iconDarkStorageKey: "icon-dark",
|
||||||
|
iconDarkMimeType: "image/svg+xml",
|
||||||
|
},
|
||||||
|
"icon",
|
||||||
|
"dark",
|
||||||
|
);
|
||||||
|
expect(asset).toEqual({
|
||||||
|
storageKey: "icon-dark",
|
||||||
|
mimeType: "image/svg+xml",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the legacy combined light logo", () => {
|
||||||
|
const business = {
|
||||||
|
logoStorageKey: "legacy-logo",
|
||||||
|
logoMimeType: "image/jpeg",
|
||||||
|
};
|
||||||
|
expect(hasBusinessBrandAsset(business)).toBe(true);
|
||||||
|
expect(resolveBusinessBrandAsset(business, "wordmark", "dark")).toEqual({
|
||||||
|
storageKey: "legacy-logo",
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds a role and theme-aware public URL", () => {
|
||||||
|
expect(businessBrandAssetPath("abc", "icon", "dark", "png")).toBe(
|
||||||
|
"/api/business-logo/abc?kind=icon&theme=dark&format=png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user