feat: add business brand assets and health checks
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./brand-assets": "./src/brand-assets.ts",
|
||||
"./expense-categories": "./src/expense-categories.ts",
|
||||
"./invoice-status": "./src/invoice-status.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 "./invoice-status";
|
||||
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