Unify the dashboard experience and retire the multi-theme engine so onboarding and day-to-day invoicing feel consistent and easier to maintain.
Shared layout, tabs, and sidebar timer; user onboarding and registration polish; settings danger zone and data export; chart and tRPC perf fixes; migrations for onboarding and dropped appearance columns. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export const APP_EMAIL_DOMAIN = "beenvoice.app";
|
||||
|
||||
export const PRIVACY_EMAIL = `privacy@${APP_EMAIL_DOMAIN}`;
|
||||
export const LEGAL_EMAIL = `legal@${APP_EMAIL_DOMAIN}`;
|
||||
export const SUPPORT_EMAIL = `support@${APP_EMAIL_DOMAIN}`;
|
||||
export const NOREPLY_EMAIL = `noreply@${APP_EMAIL_DOMAIN}`;
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Public app origin (no trailing slash). */
|
||||
export function getAppUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
const fromEnv = process.env.NEXT_PUBLIC_APP_URL?.trim();
|
||||
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
||||
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
/** Hostname for display (e.g. marketing browser chrome). */
|
||||
export function getAppHost(): string {
|
||||
try {
|
||||
return new URL(getAppUrl()).host;
|
||||
} catch {
|
||||
return "beenvoice.app";
|
||||
}
|
||||
}
|
||||
+4
-102
@@ -1,126 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const interfaceThemeValues = [
|
||||
"beenvoice",
|
||||
"frutiger",
|
||||
"frutiger-aero",
|
||||
"shadcn",
|
||||
"minimal",
|
||||
"editorial",
|
||||
] as const;
|
||||
export const fontPreferenceValues = [
|
||||
"brand",
|
||||
"frutiger",
|
||||
"platform",
|
||||
"inter",
|
||||
"serif",
|
||||
] as const;
|
||||
export const radiusPreferenceValues = ["none", "sm", "md", "lg", "xl"] as const;
|
||||
export const sidebarStyleValues = ["floating", "docked"] as const;
|
||||
export const colorModeValues = ["light", "dark", "system"] as const;
|
||||
export const colorThemeValues = [
|
||||
"slate",
|
||||
"blue",
|
||||
"green",
|
||||
"rose",
|
||||
"orange",
|
||||
"custom",
|
||||
] as const;
|
||||
export const pdfTemplateValues = ["classic", "minimal"] as const;
|
||||
|
||||
export const interfaceThemeSchema = z.enum(interfaceThemeValues);
|
||||
export const fontPreferenceSchema = z.enum(fontPreferenceValues);
|
||||
export const radiusPreferenceSchema = z.enum(radiusPreferenceValues);
|
||||
export const sidebarStyleSchema = z.enum(sidebarStyleValues);
|
||||
export const colorModeSchema = z.enum(colorModeValues);
|
||||
export const colorThemeSchema = z.enum(colorThemeValues);
|
||||
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
|
||||
|
||||
export const hslChannelsSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(
|
||||
/^(?:360(?:\.0)?|3[0-5]\d(?:\.\d)?|[12]?\d?\d(?:\.\d)?)\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%\s+(?:100(?:\.0)?|\d{1,2}(?:\.\d)?)%$/,
|
||||
"Use HSL channels like 142.1 76.2% 36.3%",
|
||||
);
|
||||
|
||||
export type InterfaceTheme = z.infer<typeof interfaceThemeSchema>;
|
||||
export type FontPreference = z.infer<typeof fontPreferenceSchema>;
|
||||
export type RadiusPreference = z.infer<typeof radiusPreferenceSchema>;
|
||||
export type SidebarStyle = z.infer<typeof sidebarStyleSchema>;
|
||||
export type ColorMode = z.infer<typeof colorModeSchema>;
|
||||
export type ColorTheme = z.infer<typeof colorThemeSchema>;
|
||||
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
|
||||
|
||||
export const fallbackAppearance = {
|
||||
interfaceTheme: "beenvoice",
|
||||
fontPreference: "brand",
|
||||
bodyFontPreference: "brand",
|
||||
headingFontPreference: "brand",
|
||||
radiusPreference: "xl",
|
||||
sidebarStyle: "floating",
|
||||
colorMode: "system",
|
||||
colorTheme: "slate",
|
||||
customColor: undefined,
|
||||
brandName: "beenvoice",
|
||||
brandTagline:
|
||||
"Simple and efficient invoicing for freelancers and small businesses",
|
||||
brandLogoText: "beenvoice",
|
||||
brandIcon: "$",
|
||||
pdfTemplate: "classic",
|
||||
export const defaultColorMode: ColorMode = "system";
|
||||
|
||||
export const defaultPdfSettings = {
|
||||
pdfTemplate: "classic" as PdfTemplate,
|
||||
pdfAccentColor: "#111827",
|
||||
pdfFooterText: "Professional Invoicing",
|
||||
pdfShowLogo: true,
|
||||
pdfShowPageNumbers: true,
|
||||
} satisfies {
|
||||
interfaceTheme: InterfaceTheme;
|
||||
fontPreference: FontPreference;
|
||||
bodyFontPreference: FontPreference;
|
||||
headingFontPreference: FontPreference;
|
||||
radiusPreference: RadiusPreference;
|
||||
sidebarStyle: SidebarStyle;
|
||||
colorMode: ColorMode;
|
||||
colorTheme: ColorTheme;
|
||||
customColor?: string;
|
||||
brandName: string;
|
||||
brandTagline: string;
|
||||
brandLogoText: string;
|
||||
brandIcon: string;
|
||||
pdfTemplate: PdfTemplate;
|
||||
pdfAccentColor: string;
|
||||
pdfFooterText: string;
|
||||
pdfShowLogo: boolean;
|
||||
pdfShowPageNumbers: boolean;
|
||||
};
|
||||
|
||||
export function isInterfaceTheme(value: unknown): value is InterfaceTheme {
|
||||
return interfaceThemeSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isFontPreference(value: unknown): value is FontPreference {
|
||||
return fontPreferenceSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isColorMode(value: unknown): value is ColorMode {
|
||||
return colorModeSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isColorTheme(value: unknown): value is ColorTheme {
|
||||
return colorThemeSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isRadiusPreference(value: unknown): value is RadiusPreference {
|
||||
return radiusPreferenceSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isSidebarStyle(value: unknown): value is SidebarStyle {
|
||||
return sidebarStyleSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isPdfTemplate(value: unknown): value is PdfTemplate {
|
||||
return pdfTemplateSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isHslChannels(value: unknown): value is string {
|
||||
return hslChannelsSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,10 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
|
||||
function resolveAuthBaseUrl(): string | undefined {
|
||||
// Always use the current origin in the browser so dev works on any port
|
||||
// (e.g. 3002 when 3000 is taken), without rebuilding for NEXT_PUBLIC_APP_URL.
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
|
||||
return process.env.NEXT_PUBLIC_APP_URL;
|
||||
function resolveAuthBaseUrl(): string | undefined {
|
||||
return getAppUrl();
|
||||
}
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
|
||||
+25
-276
@@ -1,292 +1,41 @@
|
||||
import { env } from "~/env";
|
||||
import {
|
||||
fallbackAppearance,
|
||||
type ColorMode,
|
||||
type ColorTheme,
|
||||
type FontPreference,
|
||||
type InterfaceTheme,
|
||||
type PdfTemplate,
|
||||
type RadiusPreference,
|
||||
type SidebarStyle,
|
||||
} from "~/lib/appearance";
|
||||
|
||||
export type {
|
||||
ColorMode,
|
||||
ColorTheme,
|
||||
FontPreference,
|
||||
InterfaceTheme,
|
||||
PdfTemplate,
|
||||
RadiusPreference,
|
||||
SidebarStyle,
|
||||
} from "~/lib/appearance";
|
||||
import { defaultColorMode, type ColorMode } from "~/lib/appearance";
|
||||
|
||||
export type { ColorMode, PdfTemplate } from "~/lib/appearance";
|
||||
export {
|
||||
colorModeSchema,
|
||||
colorThemeSchema,
|
||||
fallbackAppearance,
|
||||
fontPreferenceSchema,
|
||||
hslChannelsSchema,
|
||||
interfaceThemeSchema,
|
||||
defaultColorMode,
|
||||
defaultPdfSettings,
|
||||
pdfTemplateSchema,
|
||||
radiusPreferenceSchema,
|
||||
sidebarStyleSchema,
|
||||
} from "~/lib/appearance";
|
||||
|
||||
export const interfaceThemes: {
|
||||
value: InterfaceTheme;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "beenvoice",
|
||||
label: "beenvoice",
|
||||
description:
|
||||
"Playfair Display headings, Geist body text, and soft product chrome.",
|
||||
},
|
||||
{
|
||||
value: "frutiger",
|
||||
label: "Frutiger Airport",
|
||||
description:
|
||||
"Rectangular blue-and-yellow wayfinding UI with Frutiger typography and docked navigation.",
|
||||
},
|
||||
{
|
||||
value: "frutiger-aero",
|
||||
label: "Frutiger Aero",
|
||||
description:
|
||||
"Glossy sky-and-glass interface with Frutiger typography and softer surfaces.",
|
||||
},
|
||||
{
|
||||
value: "shadcn",
|
||||
label: "shadcn/ui",
|
||||
description: "A plain shadcn baseline for white-label starts.",
|
||||
},
|
||||
{
|
||||
value: "minimal",
|
||||
label: "Minimal",
|
||||
description: "Quiet surfaces, lower contrast, and restrained chrome.",
|
||||
},
|
||||
{
|
||||
value: "editorial",
|
||||
label: "Editorial",
|
||||
description: "A warmer presentation style for service-led brands.",
|
||||
},
|
||||
];
|
||||
|
||||
export const themePresets: Record<
|
||||
InterfaceTheme,
|
||||
{
|
||||
interfaceTheme: InterfaceTheme;
|
||||
bodyFontPreference: FontPreference;
|
||||
headingFontPreference: FontPreference;
|
||||
colorTheme: ColorTheme;
|
||||
radiusPreference: RadiusPreference;
|
||||
sidebarStyle: SidebarStyle;
|
||||
pdfTemplate: PdfTemplate;
|
||||
pdfAccentColor: string;
|
||||
}
|
||||
> = {
|
||||
beenvoice: {
|
||||
interfaceTheme: "beenvoice",
|
||||
bodyFontPreference: "brand",
|
||||
headingFontPreference: "brand",
|
||||
colorTheme: "slate",
|
||||
radiusPreference: "xl",
|
||||
sidebarStyle: "floating",
|
||||
pdfTemplate: "classic",
|
||||
pdfAccentColor: "#111827",
|
||||
},
|
||||
frutiger: {
|
||||
interfaceTheme: "frutiger",
|
||||
bodyFontPreference: "frutiger",
|
||||
headingFontPreference: "frutiger",
|
||||
colorTheme: "blue",
|
||||
radiusPreference: "none",
|
||||
sidebarStyle: "docked",
|
||||
pdfTemplate: "minimal",
|
||||
pdfAccentColor: "#003b5c",
|
||||
},
|
||||
"frutiger-aero": {
|
||||
interfaceTheme: "frutiger-aero",
|
||||
bodyFontPreference: "frutiger",
|
||||
headingFontPreference: "frutiger",
|
||||
colorTheme: "blue",
|
||||
radiusPreference: "lg",
|
||||
sidebarStyle: "floating",
|
||||
pdfTemplate: "classic",
|
||||
pdfAccentColor: "#0077be",
|
||||
},
|
||||
shadcn: {
|
||||
interfaceTheme: "shadcn",
|
||||
bodyFontPreference: "inter",
|
||||
headingFontPreference: "inter",
|
||||
colorTheme: "slate",
|
||||
radiusPreference: "md",
|
||||
sidebarStyle: "docked",
|
||||
pdfTemplate: "classic",
|
||||
pdfAccentColor: "#111827",
|
||||
},
|
||||
minimal: {
|
||||
interfaceTheme: "minimal",
|
||||
bodyFontPreference: "platform",
|
||||
headingFontPreference: "platform",
|
||||
colorTheme: "slate",
|
||||
radiusPreference: "sm",
|
||||
sidebarStyle: "docked",
|
||||
pdfTemplate: "minimal",
|
||||
pdfAccentColor: "#111827",
|
||||
},
|
||||
editorial: {
|
||||
interfaceTheme: "editorial",
|
||||
bodyFontPreference: "platform",
|
||||
headingFontPreference: "serif",
|
||||
colorTheme: "rose",
|
||||
radiusPreference: "lg",
|
||||
sidebarStyle: "floating",
|
||||
pdfTemplate: "classic",
|
||||
pdfAccentColor: "#be123c",
|
||||
},
|
||||
};
|
||||
|
||||
export const bodyFontPreferences: {
|
||||
value: FontPreference;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "brand",
|
||||
label: "Geist",
|
||||
description: "Geist body text for the core beenvoice product feel.",
|
||||
},
|
||||
{
|
||||
value: "frutiger",
|
||||
label: "Frutiger",
|
||||
description: "Frutiger body text for signage-like operational screens.",
|
||||
},
|
||||
{
|
||||
value: "platform",
|
||||
label: "Platform",
|
||||
description: "Native system body text for the current OS.",
|
||||
},
|
||||
{
|
||||
value: "inter",
|
||||
label: "Geist Legacy",
|
||||
description: "Legacy sans option mapped to Geist for older installs.",
|
||||
},
|
||||
{
|
||||
value: "serif",
|
||||
label: "Serif",
|
||||
description: "Georgia-style body text for editorial deployments.",
|
||||
},
|
||||
];
|
||||
|
||||
export const headingFontPreferences: {
|
||||
value: FontPreference;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "brand",
|
||||
label: "Playfair Display",
|
||||
description: "Playfair Display headings for the beenvoice identity.",
|
||||
},
|
||||
{
|
||||
value: "frutiger",
|
||||
label: "Frutiger",
|
||||
description: "Frutiger headings for airport-inspired wayfinding.",
|
||||
},
|
||||
{
|
||||
value: "platform",
|
||||
label: "Platform",
|
||||
description: "Native system headings for a neutral app feel.",
|
||||
},
|
||||
{
|
||||
value: "inter",
|
||||
label: "Geist Legacy",
|
||||
description: "Legacy sans option mapped to Geist for older installs.",
|
||||
},
|
||||
{
|
||||
value: "serif",
|
||||
label: "Editorial",
|
||||
description: "Playfair headings with a stronger editorial tone.",
|
||||
},
|
||||
];
|
||||
|
||||
export const radiusPreferences: {
|
||||
value: RadiusPreference;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ value: "none", label: "Square", description: "No rounded corners." },
|
||||
{ value: "sm", label: "Small", description: "Subtle 4px rounding." },
|
||||
{ value: "md", label: "Medium", description: "Standard 8px rounding." },
|
||||
{ value: "lg", label: "Large", description: "Soft 12px rounding." },
|
||||
{
|
||||
value: "xl",
|
||||
label: "Extra Large",
|
||||
description: "Expressive 16px rounding.",
|
||||
},
|
||||
];
|
||||
|
||||
export const sidebarStyles: {
|
||||
value: SidebarStyle;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "floating",
|
||||
label: "Floating",
|
||||
description: "Inset navigation with rounded edges and elevation.",
|
||||
},
|
||||
{
|
||||
value: "docked",
|
||||
label: "Flush",
|
||||
description: "Full-height navigation aligned to the viewport edge.",
|
||||
},
|
||||
];
|
||||
|
||||
export const colorThemes: {
|
||||
value: ColorTheme;
|
||||
label: string;
|
||||
swatch: string;
|
||||
}[] = [
|
||||
{ value: "slate", label: "Slate", swatch: "hsl(240 5.9% 10%)" },
|
||||
{ value: "blue", label: "Blue", swatch: "hsl(221.2 83.2% 53.3%)" },
|
||||
{ value: "green", label: "Green", swatch: "hsl(142.1 76.2% 36.3%)" },
|
||||
{ value: "rose", label: "Rose", swatch: "hsl(346.8 77.2% 49.8%)" },
|
||||
{ value: "orange", label: "Orange", swatch: "hsl(24.6 95% 53.1%)" },
|
||||
];
|
||||
|
||||
export const colorModes: {
|
||||
value: ColorMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{ value: "system", label: "System", description: "Follow device setting." },
|
||||
{ value: "light", label: "Light", description: "Always use light mode." },
|
||||
{ value: "dark", label: "Dark", description: "Always use dark mode." },
|
||||
{
|
||||
value: "system",
|
||||
label: "System",
|
||||
description: "Match your device light or dark setting.",
|
||||
},
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
description: "Always use light mode.",
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
description: "Always use dark mode.",
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultInterfaceTheme: InterfaceTheme =
|
||||
env.NEXT_PUBLIC_DEFAULT_INTERFACE_THEME ?? fallbackAppearance.interfaceTheme;
|
||||
|
||||
export const defaultFontPreference: FontPreference =
|
||||
env.NEXT_PUBLIC_DEFAULT_FONT ?? fallbackAppearance.fontPreference;
|
||||
|
||||
export const defaultBodyFontPreference: FontPreference =
|
||||
env.NEXT_PUBLIC_DEFAULT_BODY_FONT ?? defaultFontPreference;
|
||||
|
||||
export const defaultHeadingFontPreference: FontPreference =
|
||||
env.NEXT_PUBLIC_DEFAULT_HEADING_FONT ?? defaultFontPreference;
|
||||
|
||||
export const defaultRadiusPreference: RadiusPreference =
|
||||
env.NEXT_PUBLIC_DEFAULT_RADIUS ?? fallbackAppearance.radiusPreference;
|
||||
|
||||
export const defaultSidebarStyle: SidebarStyle =
|
||||
env.NEXT_PUBLIC_DEFAULT_SIDEBAR_STYLE ?? fallbackAppearance.sidebarStyle;
|
||||
|
||||
export const brand = {
|
||||
name: env.NEXT_PUBLIC_BRAND_NAME ?? fallbackAppearance.brandName,
|
||||
tagline: env.NEXT_PUBLIC_BRAND_TAGLINE ?? fallbackAppearance.brandTagline,
|
||||
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? fallbackAppearance.brandLogoText,
|
||||
icon: env.NEXT_PUBLIC_BRAND_ICON ?? fallbackAppearance.brandIcon,
|
||||
name: env.NEXT_PUBLIC_BRAND_NAME ?? "beenvoice",
|
||||
tagline:
|
||||
env.NEXT_PUBLIC_BRAND_TAGLINE ??
|
||||
"Simple and efficient invoicing for freelancers and small businesses",
|
||||
logoText: env.NEXT_PUBLIC_BRAND_LOGO_TEXT ?? "beenvoice",
|
||||
icon: env.NEXT_PUBLIC_BRAND_ICON ?? "$",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const DATABASE_SETUP_HINT =
|
||||
"Database not ready — run `bun db:migrate` (or `bun db:push` for local dev) after starting Postgres.";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function collectErrorParts(error: unknown): string[] {
|
||||
const parts: string[] = [];
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = error;
|
||||
|
||||
while (current && isRecord(current) && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
|
||||
if (typeof current.message === "string") {
|
||||
parts.push(current.message);
|
||||
}
|
||||
|
||||
if (typeof current.code === "string") {
|
||||
parts.push(current.code);
|
||||
}
|
||||
|
||||
current = current.cause;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function getDatabaseSetupErrorMessage(error: unknown): string | null {
|
||||
const haystack = collectErrorParts(error).join(" ").toLowerCase();
|
||||
|
||||
if (
|
||||
haystack.includes("does not exist") ||
|
||||
haystack.includes("42p01") ||
|
||||
haystack.includes("econnrefused") ||
|
||||
haystack.includes("connection refused") ||
|
||||
haystack.includes("connect econnrefused")
|
||||
) {
|
||||
return DATABASE_SETUP_HINT;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
|
||||
interface InvoiceEmailTemplateProps {
|
||||
invoice: {
|
||||
invoiceNumber: string;
|
||||
@@ -44,7 +46,7 @@ export function generateInvoiceEmailTemplate({
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl: _baseUrl = "https://beenvoice.app",
|
||||
baseUrl = getAppUrl(),
|
||||
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatEmailDate } from "src/lib/email-utils";
|
||||
import { SUPPORT_EMAIL } from "~/lib/app-email";
|
||||
|
||||
interface PasswordResetEmailProps {
|
||||
userEmail: string;
|
||||
@@ -188,7 +189,7 @@ export function generatePasswordResetEmailTemplate({
|
||||
</p>
|
||||
<p>
|
||||
beenvoice - Professional invoicing made simple<br>
|
||||
<a href="mailto:support@beenvoice.com">support@beenvoice.com</a>
|
||||
<a href="mailto:${SUPPORT_EMAIL}">${SUPPORT_EMAIL}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +214,7 @@ SECURITY INFORMATION:
|
||||
|
||||
If you're having trouble with the link, copy and paste the entire URL into your browser's address bar.
|
||||
|
||||
If you have any questions or need assistance, please contact our support team at support@beenvoice.com.
|
||||
If you have any questions or need assistance, please contact our support team at ${SUPPORT_EMAIL}.
|
||||
|
||||
Best regards,
|
||||
The beenvoice Team
|
||||
|
||||
+6
-3
@@ -1,5 +1,8 @@
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { LEGAL_EMAIL, PRIVACY_EMAIL } from "~/lib/app-email";
|
||||
|
||||
export const LEGAL_LAST_UPDATED = "June 18, 2026";
|
||||
|
||||
export const LEGAL_PRIVACY_EMAIL = "privacy@soconnor.dev";
|
||||
export const LEGAL_TERMS_EMAIL = "legal@soconnor.dev";
|
||||
export const LEGAL_WEBSITE = "https://beenvoice.soconnor.dev";
|
||||
export const LEGAL_PRIVACY_EMAIL = PRIVACY_EMAIL;
|
||||
export const LEGAL_TERMS_EMAIL = LEGAL_EMAIL;
|
||||
export const LEGAL_WEBSITE = getAppUrl();
|
||||
|
||||
@@ -32,6 +32,19 @@ export function isNavLinkActive(pathname: string, href: string): boolean {
|
||||
return pathname === href;
|
||||
}
|
||||
|
||||
const ADMIN_ONLY_HREFS = new Set(["/dashboard/administration"]);
|
||||
|
||||
export function getNavigationForUser(isAdmin: boolean): NavSection[] {
|
||||
return navigationConfig
|
||||
.map((section) => ({
|
||||
...section,
|
||||
links: section.links.filter(
|
||||
(link) => isAdmin || !ADMIN_ONLY_HREFS.has(link.href),
|
||||
),
|
||||
}))
|
||||
.filter((section) => section.links.length > 0);
|
||||
}
|
||||
|
||||
export const navigationConfig: NavSection[] = [
|
||||
{
|
||||
title: "Main",
|
||||
|
||||
+19
-5
@@ -15,6 +15,9 @@ const PLURALIZATION_RULES: Record<
|
||||
tax: { singular: "Tax", plural: "Taxes" },
|
||||
category: { singular: "Category", plural: "Categories" },
|
||||
company: { singular: "Company", plural: "Companies" },
|
||||
entity: { singular: "Entity", plural: "Entities" },
|
||||
expense: { singular: "Expense", plural: "Expenses" },
|
||||
report: { singular: "Report", plural: "Reports" },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -105,20 +108,31 @@ export function capitalize(word: string): string {
|
||||
* Get a properly formatted label for a route segment
|
||||
*/
|
||||
export function getRouteLabel(segment: string, isPlural = true): string {
|
||||
// First, check if it's already in our rules
|
||||
const rule = PLURALIZATION_RULES[segment.toLowerCase()];
|
||||
const lower = segment.toLowerCase();
|
||||
|
||||
// Route segments are often already plural (e.g. "entities", "invoices")
|
||||
const ruleByPlural = Object.values(PLURALIZATION_RULES).find(
|
||||
(r) => r.plural.toLowerCase() === lower,
|
||||
);
|
||||
if (ruleByPlural) {
|
||||
return isPlural ? ruleByPlural.plural : ruleByPlural.singular;
|
||||
}
|
||||
|
||||
const rule = PLURALIZATION_RULES[lower];
|
||||
if (rule) {
|
||||
return isPlural ? rule.plural : rule.singular;
|
||||
}
|
||||
|
||||
// If not, try to find it by plural form
|
||||
const singularForm = singularize(segment);
|
||||
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
|
||||
if (singularRule) {
|
||||
return isPlural ? singularRule.plural : singularRule.singular;
|
||||
}
|
||||
|
||||
// Otherwise, just capitalize and optionally pluralize
|
||||
const capitalized = capitalize(segment);
|
||||
return isPlural ? pluralize(capitalized) : capitalized;
|
||||
if (isPlural && /s$/i.test(segment)) {
|
||||
return capitalized;
|
||||
}
|
||||
|
||||
return isPlural ? pluralize(capitalized) : capitalize(singularForm);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user