Add 'apps/web/' from commit '1e7174fa604b11e7c3983cd8ad01c596f6e77e96'
git-subtree-dir: apps/web git-subtree-mainline:068a51b46bgit-subtree-split:1e7174fa60
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,39 @@
|
||||
/** 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Origin derived from an incoming request's own headers — robust against
|
||||
* NEXT_PUBLIC_APP_URL/BETTER_AUTH_URL drifting from the port the server is
|
||||
* actually reachable on (e.g. local dev when the configured port is taken).
|
||||
* Falls back to getAppUrl() if the request has no usable host header.
|
||||
*/
|
||||
export function getRequestOrigin(headers: Headers): string {
|
||||
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||
if (!host) return getAppUrl();
|
||||
|
||||
const forwardedProto = headers.get("x-forwarded-proto");
|
||||
const protocol =
|
||||
forwardedProto ?? (host.startsWith("localhost:") || host.startsWith("127.0.0.1:")
|
||||
? "http"
|
||||
: "https");
|
||||
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
/** Hostname for display (e.g. marketing browser chrome). */
|
||||
export function getAppHost(): string {
|
||||
try {
|
||||
return new URL(getAppUrl()).host;
|
||||
} catch {
|
||||
return "beenvoice.app";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
pdfFontFamilySchema,
|
||||
type PdfFontFamily,
|
||||
} from "~/lib/pdf-fonts";
|
||||
|
||||
export const colorModeValues = ["light", "dark", "system"] as const;
|
||||
export const pdfTemplateValues = ["classic", "minimal"] as const;
|
||||
|
||||
export const colorModeSchema = z.enum(colorModeValues);
|
||||
export const pdfTemplateSchema = z.enum(pdfTemplateValues);
|
||||
|
||||
export { pdfFontFamilySchema, type PdfFontFamily };
|
||||
|
||||
export type ColorMode = z.infer<typeof colorModeSchema>;
|
||||
export type PdfTemplate = z.infer<typeof pdfTemplateSchema>;
|
||||
|
||||
export const defaultColorMode: ColorMode = "system";
|
||||
|
||||
export const defaultPdfSettings = {
|
||||
pdfTemplate: "classic" as PdfTemplate,
|
||||
pdfAccentColor: "#111827",
|
||||
pdfFontFamily: "sans" as PdfFontFamily,
|
||||
pdfNumericFontFamily: "mono" as PdfFontFamily,
|
||||
pdfFooterText: "Professional Invoicing",
|
||||
pdfShowLogo: true,
|
||||
pdfShowPageNumbers: true,
|
||||
};
|
||||
|
||||
export function isColorMode(value: unknown): value is ColorMode {
|
||||
return colorModeSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isPdfTemplate(value: unknown): value is PdfTemplate {
|
||||
return pdfTemplateSchema.safeParse(value).success;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { db } from "~/server/db";
|
||||
import { auditLog } from "~/server/db/schema";
|
||||
|
||||
export type AuditAction =
|
||||
| "user.profile_updated"
|
||||
| "user.role_updated"
|
||||
| "user.password_reset_sent"
|
||||
| "platform.pdf_settings_updated";
|
||||
|
||||
export type AuditTargetType = "user" | "platform";
|
||||
|
||||
type LogAuditEventInput = {
|
||||
actorUserId: string;
|
||||
action: AuditAction;
|
||||
targetType: AuditTargetType;
|
||||
targetId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function logAuditEvent(input: LogAuditEventInput): Promise<void> {
|
||||
await db.insert(auditLog).values({
|
||||
actorUserId: input.actorUserId,
|
||||
action: input.action,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
|
||||
function resolveAuthBaseUrl(): string | undefined {
|
||||
return getAppUrl();
|
||||
}
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: resolveAuthBaseUrl(),
|
||||
plugins: [genericOAuthClient()],
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { auth } from "~/lib/auth";
|
||||
|
||||
const MOBILE_AUTH_COOKIE_HEADER = "x-beenvoice-auth-cookie";
|
||||
const MOBILE_SESSION_TOKEN_HEADER = "x-beenvoice-session-token";
|
||||
const MAX_AUTH_COOKIE_HEADER_LENGTH = 16 * 1024;
|
||||
const MAX_SESSION_TOKEN_LENGTH = 255;
|
||||
const SESSION_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]+$/;
|
||||
|
||||
function looksLikeSessionCookie(cookie: string): boolean {
|
||||
return cookie.split(";").some((part) => {
|
||||
const name =
|
||||
part
|
||||
.trim()
|
||||
.split("=", 1)[0]
|
||||
?.replace(/^__Secure-/, "") ?? "";
|
||||
return (
|
||||
name === "better-auth.session_token" ||
|
||||
name === "better-auth.session_data" ||
|
||||
name.startsWith("better-auth.session_token.") ||
|
||||
name.startsWith("better-auth.session_data.") ||
|
||||
name.endsWith(".session_token") ||
|
||||
name.endsWith(".session_data") ||
|
||||
name.includes(".session_token.") ||
|
||||
name.includes(".session_data.")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function headersWithAuthCookieFallback(headers: Headers): Headers {
|
||||
const mobileCookie = headers.get(MOBILE_AUTH_COOKIE_HEADER)?.trim();
|
||||
if (
|
||||
mobileCookie &&
|
||||
mobileCookie.length <= MAX_AUTH_COOKIE_HEADER_LENGTH &&
|
||||
looksLikeSessionCookie(mobileCookie)
|
||||
) {
|
||||
const nextHeaders = new Headers(headers);
|
||||
nextHeaders.set("cookie", mobileCookie);
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
if (headers.get("cookie")?.trim()) return headers;
|
||||
|
||||
const sessionToken = headers.get(MOBILE_SESSION_TOKEN_HEADER)?.trim();
|
||||
if (
|
||||
sessionToken &&
|
||||
sessionToken.length <= MAX_SESSION_TOKEN_LENGTH &&
|
||||
SESSION_TOKEN_PATTERN.test(sessionToken)
|
||||
) {
|
||||
const nextHeaders = new Headers(headers);
|
||||
nextHeaders.set(
|
||||
"cookie",
|
||||
[
|
||||
`better-auth.session_token=${sessionToken}`,
|
||||
`__Secure-better-auth.session_token=${sessionToken}`,
|
||||
].join("; "),
|
||||
);
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function hasSessionCookie(headers: Headers): boolean {
|
||||
const cookie = headers.get("cookie") ?? "";
|
||||
if (!cookie.trim()) return false;
|
||||
|
||||
return looksLikeSessionCookie(cookie);
|
||||
}
|
||||
|
||||
export async function getOptionalServerSession(headers: Headers) {
|
||||
headers = headersWithAuthCookieFallback(headers);
|
||||
|
||||
if (!hasSessionCookie(headers)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await auth.api.getSession({ headers });
|
||||
} catch (error) {
|
||||
console.error("[auth] Failed to resolve session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOptionalServerSessionFromHeaders() {
|
||||
return getOptionalServerSession(await nextHeaders());
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { expo } from "@better-auth/expo";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { env } from "~/env";
|
||||
import { isDemoUser, promoteFirstRealUserIfNeeded } from "~/lib/first-admin";
|
||||
import { sendPasswordResetEmail } from "~/lib/password-reset";
|
||||
import { db } from "~/server/db";
|
||||
import * as schema from "~/server/db/schema";
|
||||
|
||||
const authentikEnabled = Boolean(
|
||||
process.env.AUTHENTIK_ISSUER &&
|
||||
process.env.AUTHENTIK_CLIENT_ID &&
|
||||
process.env.AUTHENTIK_CLIENT_SECRET,
|
||||
);
|
||||
const signupsDisabled = env.DISABLE_SIGNUPS;
|
||||
|
||||
// Derive the authentik origin from the issuer URL so the OAuth callback is
|
||||
// automatically trusted without needing a separate AUTHENTIK_ORIGIN env var.
|
||||
const authentikOrigin =
|
||||
authentikEnabled && process.env.AUTHENTIK_ISSUER
|
||||
? new URL(process.env.AUTHENTIK_ISSUER).origin
|
||||
: null;
|
||||
|
||||
const staticTrustedOrigins = [
|
||||
...(process.env.BETTER_AUTH_URL ? [process.env.BETTER_AUTH_URL] : []),
|
||||
...(process.env.NEXT_PUBLIC_APP_URL ? [process.env.NEXT_PUBLIC_APP_URL] : []),
|
||||
"beenvoice://",
|
||||
...(env.NODE_ENV === "development"
|
||||
? ["exp://", "http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
: []),
|
||||
...(authentikOrigin ? [authentikOrigin] : []),
|
||||
...(process.env.AUTHENTIK_ORIGIN ? [process.env.AUTHENTIK_ORIGIN] : []),
|
||||
];
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: process.env.BETTER_AUTH_URL,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
advanced: {
|
||||
trustedProxyHeaders: true,
|
||||
// Login from a LAN IP, ngrok tunnel, or the Expo dev client hits the API
|
||||
// from an Origin that's rarely worth adding to trustedOrigins ahead of
|
||||
// time. Skip the Origin/CSRF check in dev only; production still
|
||||
// enforces it via trustedOrigins above.
|
||||
...(env.NODE_ENV === "development" ? { disableCSRFCheck: true } : {}),
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
window: 60,
|
||||
max: 100,
|
||||
customRules: {
|
||||
"/sign-in/email": {
|
||||
window: 60,
|
||||
max: 10,
|
||||
},
|
||||
"/sign-up/email": {
|
||||
window: 60 * 60,
|
||||
max: 5,
|
||||
},
|
||||
"/request-password-reset": {
|
||||
window: 60 * 60,
|
||||
max: 5,
|
||||
},
|
||||
"/reset-password": {
|
||||
window: 60,
|
||||
max: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
joins: true,
|
||||
},
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
user: schema.users,
|
||||
session: schema.sessions,
|
||||
account: schema.accounts,
|
||||
verification: schema.verificationTokens,
|
||||
},
|
||||
}),
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
if (isDemoUser(user)) {
|
||||
return;
|
||||
}
|
||||
await promoteFirstRealUserIfNeeded(user.id);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
trustedOrigins: staticTrustedOrigins,
|
||||
...(authentikEnabled && {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ["authentik"],
|
||||
},
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
disableSignUp: signupsDisabled,
|
||||
minPasswordLength: 8,
|
||||
resetPasswordTokenExpiresIn: 60 * 60,
|
||||
revokeSessionsOnPasswordReset: true,
|
||||
sendResetPassword: async ({ user, token }) => {
|
||||
await sendPasswordResetEmail({
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? undefined,
|
||||
resetToken: token,
|
||||
});
|
||||
},
|
||||
password: {
|
||||
hash: async (password) => {
|
||||
const bcrypt = await import("bcryptjs");
|
||||
return bcrypt.hash(password, 12);
|
||||
},
|
||||
verify: async ({ hash, password }) => {
|
||||
const bcrypt = await import("bcryptjs");
|
||||
return bcrypt.compare(password, hash);
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
expo(),
|
||||
nextCookies(),
|
||||
...(authentikEnabled
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "authentik",
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET!,
|
||||
discoveryUrl: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
pkce: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { env } from "~/env";
|
||||
import { type ColorMode } from "~/lib/appearance";
|
||||
|
||||
export type { ColorMode, PdfFontFamily, PdfTemplate } from "~/lib/appearance";
|
||||
export {
|
||||
colorModeSchema,
|
||||
defaultColorMode,
|
||||
defaultPdfSettings,
|
||||
pdfFontFamilySchema,
|
||||
pdfTemplateSchema,
|
||||
} from "~/lib/appearance";
|
||||
|
||||
export const colorModes: {
|
||||
value: ColorMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
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 brand = {
|
||||
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 ?? "$",
|
||||
};
|
||||
|
||||
/** Split logo text for the `$ been` / `voice` styling used in Logo and OG images. */
|
||||
export function splitLogoText(logoText: string) {
|
||||
const voiceIndex = logoText.toLowerCase().indexOf("voice");
|
||||
|
||||
if (voiceIndex > 0) {
|
||||
return [logoText.slice(0, voiceIndex), logoText.slice(voiceIndex)] as const;
|
||||
}
|
||||
|
||||
return [
|
||||
logoText.slice(0, Math.ceil(logoText.length / 2)),
|
||||
logoText.slice(Math.ceil(logoText.length / 2)),
|
||||
] as const;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
export function hexToRgb(hex: string) {
|
||||
const normalized = normalizeHex(hex).slice(1, 7);
|
||||
return {
|
||||
r: parseInt(normalized.slice(0, 2), 16),
|
||||
g: parseInt(normalized.slice(2, 4), 16),
|
||||
b: parseInt(normalized.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbToHex(r: number, g: number, b: number) {
|
||||
return `#${[r, g, b]
|
||||
.map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0"))
|
||||
.join("")}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function rgbToHsl(r: number, g: number, b: number) {
|
||||
const red = clamp(r, 0, 255) / 255;
|
||||
const green = clamp(g, 0, 255) / 255;
|
||||
const blue = clamp(b, 0, 255) / 255;
|
||||
const max = Math.max(red, green, blue);
|
||||
const min = Math.min(red, green, blue);
|
||||
const lightness = (max + min) / 2;
|
||||
const delta = max - min;
|
||||
|
||||
if (delta === 0) {
|
||||
return { h: 0, s: 0, l: Math.round(lightness * 100) };
|
||||
}
|
||||
|
||||
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
|
||||
const hue =
|
||||
max === red
|
||||
? 60 * (((green - blue) / delta) % 6)
|
||||
: max === green
|
||||
? 60 * ((blue - red) / delta + 2)
|
||||
: 60 * ((red - green) / delta + 4);
|
||||
|
||||
return {
|
||||
h: Math.round((hue + 360) % 360),
|
||||
s: Math.round(saturation * 100),
|
||||
l: Math.round(lightness * 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function hslToRgb(h: number, s: number, l: number) {
|
||||
const hue = clamp(h, 0, 360);
|
||||
const saturation = clamp(s, 0, 100) / 100;
|
||||
const lightness = clamp(l, 0, 100) / 100;
|
||||
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
|
||||
const m = lightness - c / 2;
|
||||
const [red, green, blue] =
|
||||
hue < 60
|
||||
? [c, x, 0]
|
||||
: hue < 120
|
||||
? [x, c, 0]
|
||||
: hue < 180
|
||||
? [0, c, x]
|
||||
: hue < 240
|
||||
? [0, x, c]
|
||||
: hue < 300
|
||||
? [x, 0, c]
|
||||
: [c, 0, x];
|
||||
|
||||
return {
|
||||
r: Math.round((red + m) * 255),
|
||||
g: Math.round((green + m) * 255),
|
||||
b: Math.round((blue + m) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
export function hexToRgba(hex: string) {
|
||||
const normalized = normalizeHex(hex, true);
|
||||
const rgb = hexToRgb(normalized);
|
||||
const alphaHex = normalized.length === 9 ? normalized.slice(7, 9) : "ff";
|
||||
return {
|
||||
...rgb,
|
||||
a: Number((parseInt(alphaHex, 16) / 255).toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbaToHex(r: number, g: number, b: number, a: number) {
|
||||
const alpha = clamp(Math.round(clampAlpha(a) * 255), 0, 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${rgbToHex(r, g, b)}${alpha}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function rgbaToHsla(r: number, g: number, b: number, a: number) {
|
||||
return { ...rgbToHsl(r, g, b), a: clampAlpha(a) };
|
||||
}
|
||||
|
||||
export function hslaToRgba(h: number, s: number, l: number, a: number) {
|
||||
return { ...hslToRgb(h, s, l), a: clampAlpha(a) };
|
||||
}
|
||||
|
||||
function normalizeHex(hex: string, alpha = false) {
|
||||
const fallback = alpha ? "#FFFFFFff" : "#FFFFFF";
|
||||
const withHash = hex.startsWith("#") ? hex : `#${hex}`;
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(withHash)) return withHash;
|
||||
if (alpha && /^#[0-9A-Fa-f]{8}$/.test(withHash)) return withHash;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(
|
||||
min,
|
||||
Math.min(max, Math.floor(Number.isFinite(value) ? value : min)),
|
||||
);
|
||||
}
|
||||
|
||||
function clampAlpha(value: number) {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 1));
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
type Oklch = {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a hexadecimal color string to an Oklch color object.
|
||||
*
|
||||
* @param {string} hex - The hexadecimal color string (e.g., "#RRGGBB", "RRGGBB", "#RGB", "RGB").
|
||||
* @returns {Oklch} The Oklch color object.
|
||||
* @throws {Error} If the hex color format is invalid.
|
||||
*/
|
||||
export function hexToOklch(hex: string): Oklch {
|
||||
const rgb = hexToRgb(hex);
|
||||
const linear_rgb = rgb.map(srgbToLinearRgb) as [number, number, number];
|
||||
const xyz = linearRgbToXyz(linear_rgb);
|
||||
const oklab = xyzToOklab(xyz);
|
||||
const oklch = oklabToOklch(oklab);
|
||||
|
||||
return {
|
||||
l: oklch[0] || 0,
|
||||
c: oklch[1] || 0,
|
||||
h: oklch[2] || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateAccentColors(hex: string) {
|
||||
const base = hexToOklch(hex);
|
||||
|
||||
const light = {
|
||||
"--background": `oklch(0.99 ${base.c * 0.05} ${base.h})`,
|
||||
"--foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--card": `oklch(1 ${base.c * 0.02} ${base.h})`,
|
||||
"--card-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--popover": `oklch(1 ${base.c * 0.02} ${base.h})`,
|
||||
"--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--primary": `oklch(0.6 ${base.c} ${base.h})`,
|
||||
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
|
||||
"--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
|
||||
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
|
||||
"--muted-foreground": `oklch(0.5 ${base.c * 0.4} ${base.h})`,
|
||||
"--accent": `oklch(0.98 ${base.c * 0.6} ${base.h})`,
|
||||
"--accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
|
||||
"--destructive": "oklch(0.58 0.24 28)",
|
||||
"--destructive-foreground": "oklch(0.98 0.01 230)",
|
||||
"--success": "oklch(0.55 0.15 142)",
|
||||
"--success-foreground": "oklch(0.98 0.01 230)",
|
||||
"--warning": "oklch(0.65 0.15 38)",
|
||||
"--warning-foreground": "oklch(0.2 0.03 230)",
|
||||
"--border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
||||
"--input": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
||||
"--ring": `oklch(0.6 ${base.c} ${base.h})`,
|
||||
"--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`,
|
||||
"--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`,
|
||||
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
|
||||
"--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
|
||||
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
||||
"--sidebar-ring": `oklch(0.6 ${base.c} ${base.h})`,
|
||||
"--navbar": `oklch(1 ${base.c * 0.02} ${base.h})`,
|
||||
"--navbar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--navbar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
|
||||
};
|
||||
|
||||
const dark = {
|
||||
"--background": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--card": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
|
||||
"--card-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`,
|
||||
"--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--primary": `oklch(0.7 ${base.c} ${base.h})`,
|
||||
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
|
||||
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`,
|
||||
"--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`,
|
||||
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
|
||||
"--accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--destructive": "oklch(0.7 0.19 22)",
|
||||
"--destructive-foreground": "oklch(0.2 0.03 230)",
|
||||
"--success": "oklch(0.6 0.15 142)",
|
||||
"--success-foreground": "oklch(0.98 0.01 230)",
|
||||
"--warning": "oklch(0.7 0.15 38)",
|
||||
"--warning-foreground": "oklch(0.2 0.03 230)",
|
||||
"--border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
|
||||
"--input": `oklch(0.35 ${base.c * 0.4} ${base.h})`,
|
||||
"--ring": `oklch(0.7 ${base.c} ${base.h})`,
|
||||
"--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
|
||||
"--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`,
|
||||
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
|
||||
base.c * 0.2
|
||||
} ${base.h})`,
|
||||
"--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
|
||||
"--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
|
||||
"--sidebar-ring": `oklch(0.7 ${base.c} ${base.h})`,
|
||||
"--navbar": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
|
||||
"--navbar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
|
||||
"--navbar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
|
||||
};
|
||||
|
||||
return { light, dark };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a hexadecimal color string to an array of R, G, B components (0-255).
|
||||
* Supports "#RRGGBB", "RRGGBB", "#RGB", "RGB" formats.
|
||||
* @param {string} hex - The hexadecimal color string.
|
||||
* @returns {number[]} An array [r, g, b].
|
||||
* @throws {Error} If the hex color format is invalid.
|
||||
*/
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
|
||||
// Remove '#' if present
|
||||
if (hex.startsWith("#")) {
|
||||
hex = hex.slice(1);
|
||||
}
|
||||
|
||||
// Handle 3-digit hex (e.g., "F0C" -> "FF00CC")
|
||||
if (hex.length === 3) {
|
||||
const chars = hex.split("");
|
||||
if (
|
||||
chars.length === 3 &&
|
||||
chars.every((char) => /^[0-9A-Fa-f]$/.test(char))
|
||||
) {
|
||||
r = parseInt(chars[0]! + chars[0]!, 16);
|
||||
g = parseInt(chars[1]! + chars[1]!, 16);
|
||||
b = parseInt(chars[2]! + chars[2]!, 16);
|
||||
} else {
|
||||
throw new Error("Invalid 3-digit hex color format.");
|
||||
}
|
||||
}
|
||||
// Handle 6-digit hex (e.g., "FF00CC")
|
||||
else if (hex.length === 6) {
|
||||
const rStr = hex.substring(0, 2);
|
||||
const gStr = hex.substring(2, 4);
|
||||
const bStr = hex.substring(4, 6);
|
||||
|
||||
if (
|
||||
/^[0-9A-Fa-f]{2}$/.test(rStr) &&
|
||||
/^[0-9A-Fa-f]{2}$/.test(gStr) &&
|
||||
/^[0-9A-Fa-f]{2}$/.test(bStr)
|
||||
) {
|
||||
r = parseInt(rStr, 16);
|
||||
g = parseInt(gStr, 16);
|
||||
b = parseInt(bStr, 16);
|
||||
} else {
|
||||
throw new Error("Invalid 6-digit hex color format.");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid hex color format. Use #RRGGBB or #RGB.");
|
||||
}
|
||||
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an sRGB component (0-255) to a linear sRGB component (0-1).
|
||||
* @param {number} c - The sRGB component value (0-255).
|
||||
* @returns {number} The linear sRGB component value (0-1).
|
||||
*/
|
||||
function srgbToLinearRgb(c: number) {
|
||||
c /= 255; // Normalize to [0, 1]
|
||||
// Apply the sRGB gamma correction formula.
|
||||
if (c <= 0.04045) {
|
||||
return c / 12.92;
|
||||
} else {
|
||||
return Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies a 3x3 matrix by a 3-element vector.
|
||||
* @param {number[][]} matrix - The 3x3 matrix.
|
||||
* @param {number[]} vector - The 3-element vector.
|
||||
* @returns {number[]} The resulting 3-element vector.
|
||||
*/
|
||||
function multiplyMatrix(
|
||||
matrix: number[][],
|
||||
vector: number[],
|
||||
): [number, number, number] {
|
||||
const result = new Array(matrix.length).fill(0) as number[];
|
||||
for (let i = 0; i < matrix.length; i++) {
|
||||
for (let j = 0; j < vector.length; j++) {
|
||||
result[i]! += matrix[i]![j]! * vector[j]!;
|
||||
}
|
||||
}
|
||||
return [result[0]!, result[1]!, result[2]!];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts linear sRGB values to CIE XYZ values (D65 white point).
|
||||
* @param {number[]} rgb_linear - An array [r, g, b] of linear sRGB components (0-1).
|
||||
* @returns {number[]} An array [X, Y, Z] of CIE XYZ components.
|
||||
*/
|
||||
function linearRgbToXyz(
|
||||
rgb_linear: [number, number, number],
|
||||
): [number, number, number] {
|
||||
// Standard sRGB to XYZ D65 conversion matrix.
|
||||
const M_srgb_to_xyz = [
|
||||
[0.4123908, 0.35758434, 0.18048079],
|
||||
[0.21263901, 0.71516868, 0.07219232],
|
||||
[0.01933082, 0.11919478, 0.95053215],
|
||||
];
|
||||
return multiplyMatrix(M_srgb_to_xyz, rgb_linear);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts CIE XYZ values to Oklab values.
|
||||
* @param {number[]} xyz - An array [X, Y, Z] of CIE XYZ components.
|
||||
* @returns {number[]} An array [L, a, b] of Oklab components.
|
||||
*/
|
||||
function xyzToOklab(xyz: [number, number, number]): [number, number, number] {
|
||||
// Convert XYZ to LMS (linear cone responses).
|
||||
const M_xyz_to_lms = [
|
||||
[0.81890226, 0.03298366, 0.05591174],
|
||||
[0.36186742, 0.638518, 0.00083942],
|
||||
[0, 0, 0.82521],
|
||||
];
|
||||
const lms = multiplyMatrix(M_xyz_to_lms, xyz);
|
||||
|
||||
// Apply cube root non-linearity to LMS values.
|
||||
const lms_prime = lms.map((val) => Math.cbrt(val)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
|
||||
// Convert LMS' to Oklab.
|
||||
const M_lms_prime_to_oklab = [
|
||||
[0.2104542553, 0.793617785, -0.0040720468],
|
||||
[1.9779984951, -2.428592205, 0.4505937099],
|
||||
[0.0259040371, 0.7827717662, -0.808675766],
|
||||
];
|
||||
return multiplyMatrix(M_lms_prime_to_oklab, lms_prime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Oklab values to Oklch values.
|
||||
* @param {number[]} oklab - An array [L, a, b] of Oklab components (L in 0-1).
|
||||
* @returns {number[]} An array [L, C, h] of Oklch components (L in 0-100, h in degrees).
|
||||
*/
|
||||
function oklabToOklch(oklab: number[]): [number, number, number] {
|
||||
const L = oklab[0] ?? 0; // Oklab L is 0-1
|
||||
const a = oklab[1] ?? 0;
|
||||
const b = oklab[2] ?? 0;
|
||||
|
||||
const C = Math.sqrt(a * a + b * b); // Chroma
|
||||
let h = Math.atan2(b, a) * (180 / Math.PI); // Hue in degrees
|
||||
|
||||
// Normalize hue to [0, 360)
|
||||
if (h < 0) {
|
||||
h += 360;
|
||||
}
|
||||
|
||||
// Oklch L is typically scaled to 0-100.
|
||||
return [L, C, h];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export const SUPPORTED_CURRENCIES = [
|
||||
{ code: "USD", label: "USD – US Dollar" },
|
||||
{ code: "EUR", label: "EUR – Euro" },
|
||||
{ code: "GBP", label: "GBP – British Pound" },
|
||||
{ code: "CAD", label: "CAD – Canadian Dollar" },
|
||||
{ code: "AUD", label: "AUD – Australian Dollar" },
|
||||
{ code: "NZD", label: "NZD – New Zealand Dollar" },
|
||||
{ code: "CHF", label: "CHF – Swiss Franc" },
|
||||
{ code: "JPY", label: "JPY – Japanese Yen" },
|
||||
{ code: "SGD", label: "SGD – Singapore Dollar" },
|
||||
{ code: "HKD", label: "HKD – Hong Kong Dollar" },
|
||||
{ code: "SEK", label: "SEK – Swedish Krona" },
|
||||
{ code: "NOK", label: "NOK – Norwegian Krone" },
|
||||
{ code: "DKK", label: "DKK – Danish Krone" },
|
||||
{ code: "MXN", label: "MXN – Mexican Peso" },
|
||||
{ code: "BRL", label: "BRL – Brazilian Real" },
|
||||
{ code: "INR", label: "INR – Indian Rupee" },
|
||||
{ code: "ZAR", label: "ZAR – South African Rand" },
|
||||
] as const;
|
||||
|
||||
export type CurrencyCode = (typeof SUPPORTED_CURRENCIES)[number]["code"];
|
||||
|
||||
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Default invoice number format (matches web/mobile create forms). */
|
||||
export function generateInvoiceNumber(now = new Date()): string {
|
||||
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
||||
return `INV-${date}-${String(now.getTime()).slice(-6)}`;
|
||||
}
|
||||
|
||||
export function defaultDueDate(issueDate: Date): Date {
|
||||
const due = new Date(issueDate);
|
||||
due.setDate(due.getDate() + 30);
|
||||
return due;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { generateInvoiceEmailTemplate } from "./invoice-email";
|
||||
export { generatePasswordResetEmailTemplate } from "./password-reset-email";
|
||||
@@ -0,0 +1,586 @@
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
|
||||
// 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
|
||||
// non-raster logos are requested through the same on-the-fly PNG
|
||||
// rasterization the PDF export uses.
|
||||
function resolveEmailLogoUrl(
|
||||
business: { id?: string; logoStorageKey?: string | null; logoMimeType?: string | null } | null | undefined,
|
||||
baseUrl: string,
|
||||
): string | null {
|
||||
if (!business?.id || !business.logoStorageKey) return null;
|
||||
const needsRaster =
|
||||
business.logoMimeType != null &&
|
||||
!["image/png", "image/jpeg"].includes(business.logoMimeType);
|
||||
const path = `/api/business-logo/${business.id}${needsRaster ? "?format=png" : ""}`;
|
||||
return `${baseUrl.replace(/\/$/, "")}${path}`;
|
||||
}
|
||||
|
||||
interface InvoiceEmailTemplateProps {
|
||||
invoice: {
|
||||
invoiceNumber: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
taxRate: number;
|
||||
currency?: string | null;
|
||||
client: {
|
||||
name: string;
|
||||
email: string | null;
|
||||
};
|
||||
business?: {
|
||||
id?: string;
|
||||
name: string;
|
||||
nickname?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
logoStorageKey?: string | null;
|
||||
logoMimeType?: string | null;
|
||||
} | null;
|
||||
items: Array<{
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}>;
|
||||
};
|
||||
customContent?: string;
|
||||
customMessage?: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export function generateInvoiceEmailTemplate({
|
||||
invoice,
|
||||
customContent,
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
baseUrl = getAppUrl(),
|
||||
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: invoice.currency ?? "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getTimeOfDayGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
};
|
||||
|
||||
const subtotal = invoice.items.reduce((sum, item) => sum + item.amount, 0);
|
||||
const taxAmount = subtotal * (invoice.taxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
const logoUrl = resolveEmailLogoUrl(invoice.business, baseUrl);
|
||||
|
||||
const businessAddress = invoice.business
|
||||
? [
|
||||
invoice.business.addressLine1,
|
||||
invoice.business.addressLine2,
|
||||
invoice.business.city && invoice.business.state
|
||||
? `${invoice.business.city}, ${invoice.business.state} ${invoice.business.postalCode ?? ""}`.trim()
|
||||
: (invoice.business.city ?? invoice.business.state),
|
||||
invoice.business.country !== "United States"
|
||||
? invoice.business.country
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("<br>")
|
||||
: "";
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="format-detection" content="date=no">
|
||||
<meta name="format-detection" content="address=no">
|
||||
<meta name="format-detection" content="email=no">
|
||||
<title>Invoice ${invoice.invoiceNumber}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
line-height: 1.6;
|
||||
color: #0f0f0f;
|
||||
background-color: #f9fafb;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #0f0f0f;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
font-weight: normal;
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 24px;
|
||||
color: #0f0f0f;
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 32px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.invoice-card {
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.invoice-summary {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.invoice-number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #0f0f0f;
|
||||
margin-bottom: 8px;
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.invoice-date {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.invoice-details {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
font-weight: 600;
|
||||
padding-top: 12px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
text-align: left;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
font-weight: bold;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
text-align: right;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.business-info {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.business-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #0f0f0f;
|
||||
margin-bottom: 8px;
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.business-details {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.custom-content ul {
|
||||
margin: 16px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.custom-content li {
|
||||
margin: 8px 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
text-align: center;
|
||||
margin: 32px 0;
|
||||
padding: 24px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cta-text {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.attachment-notice {
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: #374151;
|
||||
border-radius: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-text {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
font-weight: bold;
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.signature {
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.signature-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #0f0f0f;
|
||||
margin-bottom: 4px;
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.signature-email {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #f9fafb;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #0f0f0f;
|
||||
margin: 0 auto 8px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Email client specific fixes */
|
||||
@media screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
width: 100% !important;
|
||||
max-width: 600px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Outlook specific fixes */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
/* Gmail specific fixes */
|
||||
.gmail-fix {
|
||||
border-collapse: separate !important;
|
||||
border-spacing: 0 !important;
|
||||
}
|
||||
|
||||
/* Apple Mail attachment preview fix */
|
||||
.attachment-notice {
|
||||
border: 1px solid #e5e7eb !important;
|
||||
background-color: #f9fafb !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header, .content, .footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.invoice-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.invoice-amount {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-row td {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
${logoUrl ? `<img src="${logoUrl}" alt="${invoice.business?.name ?? ""}" style="max-height: 40px; max-width: 200px; margin-bottom: 12px;">` : ""}
|
||||
<div class="header-content">Invoice ${invoice.invoiceNumber}</div>
|
||||
<div class="header-subtitle">From ${invoice.business?.name ?? "Your Business"}</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="message">
|
||||
<div class="greeting">${getTimeOfDayGreeting()},</div>
|
||||
<p>I hope this email finds you well. Please find attached invoice <strong>#${invoice.invoiceNumber}</strong>
|
||||
for the services provided. The invoice details are summarized below for your reference.</p>
|
||||
${customMessage ? `<div style="margin: 16px 0; padding: 16px; background-color: #f9fafb; border-left: 4px solid #374151; border-radius: 0;">${customMessage}</div>` : ""}
|
||||
</div>
|
||||
${customContent ? `<div class="message custom-content">${customContent}</div>` : ""}
|
||||
|
||||
<div class="invoice-card">
|
||||
<div class="invoice-summary">
|
||||
<div class="invoice-number">#${invoice.invoiceNumber}</div>
|
||||
<div class="invoice-date">Issue Date: ${formatDate(invoice.issueDate)}</div>
|
||||
<div class="invoice-date">Due Date: ${formatDate(invoice.dueDate)}</div>
|
||||
</div>
|
||||
|
||||
<div class="invoice-details">
|
||||
<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-bottom: 1px solid #f3f4f6;">
|
||||
<tr>
|
||||
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left;">Client</td>
|
||||
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right;">${invoice.client.name}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-bottom: 1px solid #f3f4f6;">
|
||||
<tr>
|
||||
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left;">Subtotal</td>
|
||||
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right;">${formatCurrency(subtotal)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
${
|
||||
invoice.taxRate > 0
|
||||
? `<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-bottom: 1px solid #f3f4f6;">
|
||||
<tr>
|
||||
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left;">Tax (${invoice.taxRate}%)</td>
|
||||
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right;">${formatCurrency(taxAmount)}</td>
|
||||
</tr>
|
||||
</table>`
|
||||
: ""
|
||||
}
|
||||
<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-top: 2px solid #e5e7eb; margin-top: 8px; padding-top: 12px;">
|
||||
<tr>
|
||||
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left; font-weight: bold; font-size: 16px;">Total</td>
|
||||
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right; font-weight: bold; font-size: 18px; color: #0f0f0f;">${formatCurrency(total)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attachment-notice">
|
||||
<div class="attachment-icon"></div>
|
||||
<div class="attachment-text">
|
||||
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta-section">
|
||||
<div class="cta-text">
|
||||
If you have any questions about this invoice, please don't hesitate to reach out.
|
||||
Thank you for your business!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
!customContent
|
||||
? `<div class="signature">
|
||||
<div class="signature-name">${userName ?? invoice.business?.name ?? "Best regards"}</div>
|
||||
${userEmail ? `<div class="signature-email">${userEmail}</div>` : ""}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-brand">beenvoice</div>
|
||||
${
|
||||
invoice.business
|
||||
? `<div class="footer-text">
|
||||
<strong>${invoice.business.name}</strong><br>
|
||||
${invoice.business.email ? `${invoice.business.email}<br>` : ""}
|
||||
${invoice.business.phone ? `${invoice.business.phone}<br>` : ""}
|
||||
${businessAddress ? `${businessAddress}` : ""}
|
||||
</div>`
|
||||
: `<div class="footer-text">
|
||||
Professional invoicing for modern businesses
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Generate plain text version
|
||||
const text = `
|
||||
${getTimeOfDayGreeting()},
|
||||
|
||||
I hope this email finds you well. Please find attached invoice #${invoice.invoiceNumber} for the services provided.${
|
||||
customMessage
|
||||
? `\n\n${customMessage
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()}`
|
||||
: ""
|
||||
}${
|
||||
customContent
|
||||
? `\n\n${customContent
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()}`
|
||||
: ""
|
||||
}
|
||||
|
||||
INVOICE DETAILS
|
||||
═══════════════
|
||||
Invoice Number: #${invoice.invoiceNumber}
|
||||
Issue Date: ${formatDate(invoice.issueDate)}
|
||||
Due Date: ${formatDate(invoice.dueDate)}
|
||||
Client: ${invoice.client.name}
|
||||
|
||||
AMOUNT BREAKDOWN
|
||||
═══════════════
|
||||
Subtotal: ${formatCurrency(subtotal)}${
|
||||
invoice.taxRate > 0
|
||||
? `\nTax (${invoice.taxRate}%): ${formatCurrency(taxAmount)}`
|
||||
: ""
|
||||
}
|
||||
Total: ${formatCurrency(total)}
|
||||
|
||||
ATTACHMENT
|
||||
═══════════════
|
||||
PDF invoice attached: invoice-${invoice.invoiceNumber}.pdf
|
||||
|
||||
If you have any questions about this invoice, please don't hesitate to reach out.
|
||||
Thank you for your business!
|
||||
|
||||
${userName ?? invoice.business?.name ?? "Best regards"}${
|
||||
userEmail ? `\n${userEmail}` : ""
|
||||
}
|
||||
|
||||
---
|
||||
${
|
||||
invoice.business
|
||||
? `${invoice.business.name}${invoice.business.email ? `\n${invoice.business.email}` : ""}${
|
||||
invoice.business.phone ? `\n${invoice.business.phone}` : ""
|
||||
}${businessAddress ? `\n${businessAddress.replace(/<br>/g, "\n")}` : ""}`
|
||||
: "Professional invoicing for modern businesses"
|
||||
}
|
||||
`.trim();
|
||||
|
||||
return { html, text };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { formatEmailDate } from "src/lib/email-utils";
|
||||
import { SUPPORT_EMAIL } from "~/lib/app-email";
|
||||
|
||||
interface PasswordResetEmailProps {
|
||||
userEmail: string;
|
||||
userName?: string;
|
||||
resetToken: string;
|
||||
resetUrl: string;
|
||||
expiryHours?: number;
|
||||
}
|
||||
|
||||
export function generatePasswordResetEmailTemplate({
|
||||
userEmail,
|
||||
userName,
|
||||
resetUrl,
|
||||
expiryHours = 24,
|
||||
}: PasswordResetEmailProps) {
|
||||
const displayName = userName ?? userEmail.split("@")[0];
|
||||
const currentDate = formatEmailDate(new Date());
|
||||
|
||||
// HTML version
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Password Reset - beenvoice</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: ui-monospace, 'Geist Mono', 'Courier New', monospace;
|
||||
line-height: 1.6;
|
||||
color: #0f0f0f;
|
||||
background-color: #f9fafb;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
background-color: #0f0f0f;
|
||||
padding: 32px 32px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.content {
|
||||
padding: 32px;
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
color: #0f0f0f;
|
||||
}
|
||||
.text {
|
||||
margin: 0 0 24px 0;
|
||||
color: #525252;
|
||||
font-size: 14px;
|
||||
}
|
||||
.button-container {
|
||||
margin: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #374151;
|
||||
color: #ffffff;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 0;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #1f2937;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
.security-notice {
|
||||
background-color: #f9fafb;
|
||||
border-left: 4px solid #374151;
|
||||
padding: 16px;
|
||||
margin: 24px 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.security-notice h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f0f0f;
|
||||
}
|
||||
.security-notice p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #525252;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f9fafb;
|
||||
padding: 24px 32px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.footer a {
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: #e5e7eb;
|
||||
margin: 24px 0;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
.header, .content, .footer {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 class="logo">
|
||||
beenvoice
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h2 class="title">Reset Your Password</h2>
|
||||
|
||||
<p class="text">Hello ${displayName},</p>
|
||||
|
||||
<p class="text">
|
||||
We received a request to reset the password for your beenvoice account.
|
||||
If you made this request, click the button below to set a new password.
|
||||
</p>
|
||||
|
||||
<div class="button-container">
|
||||
<a href="${resetUrl}" class="button">Reset Your Password</a>
|
||||
</div>
|
||||
|
||||
<p class="text">
|
||||
If the button doesn't work, copy and paste this link into your browser:
|
||||
<br>
|
||||
<a href="${resetUrl}" style="color: #374151; word-break: break-all;">${resetUrl}</a>
|
||||
</p>
|
||||
|
||||
<div class="security-notice">
|
||||
<h4>Security Information</h4>
|
||||
<p>This password reset link will expire in ${expiryHours} hours for your security.</p>
|
||||
<p>If you didn't request this password reset, you can safely ignore this email.</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="text">
|
||||
If you're having trouble accessing your account or have questions,
|
||||
please contact our support team.
|
||||
</p>
|
||||
|
||||
<p class="text">
|
||||
Best regards,<br>
|
||||
The beenvoice Team
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
This email was sent to <strong>${userEmail}</strong> on ${currentDate}.
|
||||
</p>
|
||||
<p>
|
||||
beenvoice - Professional invoicing made simple<br>
|
||||
<a href="mailto:${SUPPORT_EMAIL}">${SUPPORT_EMAIL}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Plain text version
|
||||
const text = `
|
||||
beenvoice - Password Reset
|
||||
|
||||
Hello ${displayName},
|
||||
|
||||
We received a request to reset the password for your beenvoice account.
|
||||
|
||||
To reset your password, please visit this link:
|
||||
${resetUrl}
|
||||
|
||||
SECURITY INFORMATION:
|
||||
- This link will expire in ${expiryHours} hours
|
||||
- If you didn't request this reset, you can safely ignore this email
|
||||
- Never share this link with anyone
|
||||
|
||||
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_EMAIL}.
|
||||
|
||||
Best regards,
|
||||
The beenvoice Team
|
||||
|
||||
---
|
||||
This email was sent to ${userEmail} on ${currentDate}.
|
||||
beenvoice - Professional invoicing made simple
|
||||
`;
|
||||
|
||||
return {
|
||||
html: html.trim(),
|
||||
text: text.trim(),
|
||||
subject: "Reset Your beenvoice Password",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
interface ReminderEmailTemplateProps {
|
||||
invoice: {
|
||||
invoiceNumber: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
totalAmount: number;
|
||||
currency?: string | null;
|
||||
client: { name: string; email: string | null };
|
||||
business?: {
|
||||
name: string;
|
||||
nickname?: string | null;
|
||||
email?: string | null;
|
||||
} | null;
|
||||
};
|
||||
customMessage?: string;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
export function generateReminderEmailTemplate({
|
||||
invoice,
|
||||
customMessage,
|
||||
userName,
|
||||
userEmail,
|
||||
}: ReminderEmailTemplateProps): { html: string; text: string; subject: string } {
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric" }).format(
|
||||
new Date(date),
|
||||
);
|
||||
|
||||
const formatCurrency = (amount: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: invoice.currency ?? "USD",
|
||||
}).format(amount);
|
||||
|
||||
const senderName =
|
||||
invoice.business?.name
|
||||
? invoice.business.nickname
|
||||
? `${invoice.business.name} (${invoice.business.nickname})`
|
||||
: invoice.business.name
|
||||
: userName ?? "Your service provider";
|
||||
|
||||
const isOverdue = new Date(invoice.dueDate) < new Date();
|
||||
|
||||
const subject = `Payment Reminder: Invoice ${invoice.invoiceNumber} — ${formatCurrency(invoice.totalAmount)}`;
|
||||
|
||||
const defaultMessage = isOverdue
|
||||
? `This is a friendly reminder that Invoice ${invoice.invoiceNumber} for ${formatCurrency(invoice.totalAmount)} was due on ${formatDate(invoice.dueDate)} and remains outstanding. Please arrange payment at your earliest convenience.`
|
||||
: `This is a friendly reminder that Invoice ${invoice.invoiceNumber} for ${formatCurrency(invoice.totalAmount)} is due on ${formatDate(invoice.dueDate)}. Please ensure payment is arranged before the due date.`;
|
||||
|
||||
const bodyMessage = customMessage ?? defaultMessage;
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Payment Reminder</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
|
||||
<!-- Header -->
|
||||
<tr><td style="background:#111827;padding:24px 32px;">
|
||||
<p style="margin:0;color:#f9fafb;font-size:20px;font-weight:700;">${senderName}</p>
|
||||
${userEmail ? `<p style="margin:4px 0 0;color:#9ca3af;font-size:13px;">${userEmail}</p>` : ""}
|
||||
</td></tr>
|
||||
<!-- Badge -->
|
||||
<tr><td style="padding:24px 32px 0;">
|
||||
<span style="display:inline-block;background:${isOverdue ? "#fef2f2" : "#fffbeb"};color:${isOverdue ? "#dc2626" : "#d97706"};border:1px solid ${isOverdue ? "#fecaca" : "#fde68a"};border-radius:6px;padding:4px 12px;font-size:12px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;">
|
||||
${isOverdue ? "OVERDUE" : "PAYMENT DUE"}
|
||||
</span>
|
||||
</td></tr>
|
||||
<!-- Body -->
|
||||
<tr><td style="padding:24px 32px;">
|
||||
<p style="margin:0 0 16px;color:#374151;font-size:15px;">Dear ${invoice.client.name},</p>
|
||||
<p style="margin:0 0 24px;color:#374151;font-size:15px;line-height:1.6;">${bodyMessage}</p>
|
||||
|
||||
<!-- Invoice details box -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:24px;">
|
||||
<tr><td style="padding:16px 20px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="color:#6b7280;font-size:13px;padding:4px 0;">Invoice number</td>
|
||||
<td style="color:#111827;font-size:13px;font-weight:600;text-align:right;padding:4px 0;">${invoice.invoiceNumber}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#6b7280;font-size:13px;padding:4px 0;">Issue date</td>
|
||||
<td style="color:#111827;font-size:13px;text-align:right;padding:4px 0;">${formatDate(invoice.issueDate)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#6b7280;font-size:13px;padding:4px 0;">Due date</td>
|
||||
<td style="color:${isOverdue ? "#dc2626" : "#111827"};font-size:13px;font-weight:${isOverdue ? "600" : "400"};text-align:right;padding:4px 0;">${formatDate(invoice.dueDate)}</td>
|
||||
</tr>
|
||||
<tr><td colspan="2" style="border-top:1px solid #e5e7eb;padding:8px 0 0;"></td></tr>
|
||||
<tr>
|
||||
<td style="color:#111827;font-size:15px;font-weight:700;padding:4px 0;">Amount due</td>
|
||||
<td style="color:#111827;font-size:15px;font-weight:700;text-align:right;padding:4px 0;">${formatCurrency(invoice.totalAmount)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0;color:#6b7280;font-size:13px;">If you have already made payment, please disregard this notice. Thank you for your business.</p>
|
||||
</td></tr>
|
||||
<!-- Footer -->
|
||||
<tr><td style="background:#f9fafb;border-top:1px solid #e5e7eb;padding:16px 32px;text-align:center;">
|
||||
<p style="margin:0;color:#9ca3af;font-size:12px;">Sent by ${senderName} · Powered by beenvoice</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const text = `Payment Reminder from ${senderName}
|
||||
|
||||
Dear ${invoice.client.name},
|
||||
|
||||
${bodyMessage}
|
||||
|
||||
Invoice: ${invoice.invoiceNumber}
|
||||
Issue date: ${formatDate(invoice.issueDate)}
|
||||
Due date: ${formatDate(invoice.dueDate)}
|
||||
Amount due: ${formatCurrency(invoice.totalAmount)}
|
||||
|
||||
If you have already made payment, please disregard this notice.
|
||||
Thank you for your business.
|
||||
|
||||
— ${senderName}`;
|
||||
|
||||
return { html, text, subject };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { generateInvoiceEmailTemplate } from "./email-templates";
|
||||
|
||||
// Simple test utility to verify the email template works
|
||||
export function testEmailTemplate() {
|
||||
const mockInvoice = {
|
||||
invoiceNumber: "INV-001",
|
||||
issueDate: new Date(),
|
||||
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
status: "draft",
|
||||
totalAmount: 1000,
|
||||
taxRate: 8.5,
|
||||
notes: null,
|
||||
client: {
|
||||
name: "Test Client",
|
||||
email: "client@example.com",
|
||||
},
|
||||
business: {
|
||||
name: "Test Business",
|
||||
email: "business@example.com",
|
||||
phone: "(555) 123-4567",
|
||||
addressLine1: "123 Business St",
|
||||
addressLine2: null,
|
||||
city: "Business City",
|
||||
state: "CA",
|
||||
postalCode: "12345",
|
||||
country: "United States",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
date: new Date(),
|
||||
description: "Development Services",
|
||||
hours: 10,
|
||||
rate: 100,
|
||||
amount: 1000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const template = generateInvoiceEmailTemplate({
|
||||
invoice: mockInvoice,
|
||||
userName: "John Doe",
|
||||
userEmail: "john@example.com",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
hasHtml: !!template.html,
|
||||
hasText: !!template.text,
|
||||
htmlLength: template.html.length,
|
||||
textLength: template.text.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Format currency for display
|
||||
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// Format date for email display
|
||||
export function formatEmailDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// Get time-based greeting
|
||||
export function getGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Parse env vars that Docker Compose passes as strings ("true" / "false"). */
|
||||
export function envBoolean(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const EXPENSE_CATEGORIES = [
|
||||
"Travel",
|
||||
"Meals & Entertainment",
|
||||
"Software & Subscriptions",
|
||||
"Hardware & Equipment",
|
||||
"Office Supplies",
|
||||
"Marketing",
|
||||
"Professional Services",
|
||||
"Utilities",
|
||||
"Other",
|
||||
] as const;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { and, asc, eq, ne, sql } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
/** Seeded in drizzle/0014_seed_demo_account.sql for App Store review. */
|
||||
export const DEMO_USER_EMAIL = "demo@example.com";
|
||||
export const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
|
||||
|
||||
const FIRST_USER_ADMIN_LOCK_KEY = 0x62656e76;
|
||||
|
||||
type DbTx = Pick<typeof db, "execute" | "select" | "query" | "update">;
|
||||
|
||||
export function isDemoUser(user: {
|
||||
email?: string | null;
|
||||
id?: string | null;
|
||||
}): boolean {
|
||||
const email = user.email?.toLowerCase();
|
||||
return email === DEMO_USER_EMAIL || user.id === DEMO_USER_ID;
|
||||
}
|
||||
|
||||
function nonDemoUserConditions() {
|
||||
return and(ne(users.email, DEMO_USER_EMAIL), ne(users.id, DEMO_USER_ID));
|
||||
}
|
||||
|
||||
async function acquireFirstUserAdminLock(tx: DbTx): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(${FIRST_USER_ADMIN_LOCK_KEY})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Role for a user about to be inserted. Call inside a transaction before insert.
|
||||
*/
|
||||
export async function resolveNewUserRole(tx: DbTx): Promise<"admin" | "user"> {
|
||||
await acquireFirstUserAdminLock(tx);
|
||||
|
||||
const [result] = await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(nonDemoUserConditions());
|
||||
|
||||
return (result?.count ?? 0) === 0 ? "admin" : "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote the first non-demo user to admin after Better Auth creates them (OAuth, etc.).
|
||||
* Safe under concurrent sign-ups: only one non-demo admin is ever assigned.
|
||||
*/
|
||||
export async function promoteFirstRealUserIfNeeded(userId: string): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await acquireFirstUserAdminLock(tx);
|
||||
|
||||
const user = await tx.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { id: true, email: true, role: true },
|
||||
});
|
||||
|
||||
if (!user || isDemoUser(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [adminResult] = await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(and(nonDemoUserConditions(), eq(users.role, "admin")));
|
||||
|
||||
if ((adminResult?.count ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstRealUser] = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(nonDemoUserConditions())
|
||||
.orderBy(asc(users.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (firstRealUser?.id === userId) {
|
||||
await tx.update(users).set({ role: "admin" }).where(eq(users.id, userId));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Shared form constants and utilities
|
||||
*/
|
||||
|
||||
// US States
|
||||
export const US_STATES = [
|
||||
{ value: "AL", label: "Alabama" },
|
||||
{ value: "AK", label: "Alaska" },
|
||||
{ value: "AZ", label: "Arizona" },
|
||||
{ value: "AR", label: "Arkansas" },
|
||||
{ value: "CA", label: "California" },
|
||||
{ value: "CO", label: "Colorado" },
|
||||
{ value: "CT", label: "Connecticut" },
|
||||
{ value: "DE", label: "Delaware" },
|
||||
{ value: "FL", label: "Florida" },
|
||||
{ value: "GA", label: "Georgia" },
|
||||
{ value: "HI", label: "Hawaii" },
|
||||
{ value: "ID", label: "Idaho" },
|
||||
{ value: "IL", label: "Illinois" },
|
||||
{ value: "IN", label: "Indiana" },
|
||||
{ value: "IA", label: "Iowa" },
|
||||
{ value: "KS", label: "Kansas" },
|
||||
{ value: "KY", label: "Kentucky" },
|
||||
{ value: "LA", label: "Louisiana" },
|
||||
{ value: "ME", label: "Maine" },
|
||||
{ value: "MD", label: "Maryland" },
|
||||
{ value: "MA", label: "Massachusetts" },
|
||||
{ value: "MI", label: "Michigan" },
|
||||
{ value: "MN", label: "Minnesota" },
|
||||
{ value: "MS", label: "Mississippi" },
|
||||
{ value: "MO", label: "Missouri" },
|
||||
{ value: "MT", label: "Montana" },
|
||||
{ value: "NE", label: "Nebraska" },
|
||||
{ value: "NV", label: "Nevada" },
|
||||
{ value: "NH", label: "New Hampshire" },
|
||||
{ value: "NJ", label: "New Jersey" },
|
||||
{ value: "NM", label: "New Mexico" },
|
||||
{ value: "NY", label: "New York" },
|
||||
{ value: "NC", label: "North Carolina" },
|
||||
{ value: "ND", label: "North Dakota" },
|
||||
{ value: "OH", label: "Ohio" },
|
||||
{ value: "OK", label: "Oklahoma" },
|
||||
{ value: "OR", label: "Oregon" },
|
||||
{ value: "PA", label: "Pennsylvania" },
|
||||
{ value: "RI", label: "Rhode Island" },
|
||||
{ value: "SC", label: "South Carolina" },
|
||||
{ value: "SD", label: "South Dakota" },
|
||||
{ value: "TN", label: "Tennessee" },
|
||||
{ value: "TX", label: "Texas" },
|
||||
{ value: "UT", label: "Utah" },
|
||||
{ value: "VT", label: "Vermont" },
|
||||
{ value: "VA", label: "Virginia" },
|
||||
{ value: "WA", label: "Washington" },
|
||||
{ value: "WV", label: "West Virginia" },
|
||||
{ value: "WI", label: "Wisconsin" },
|
||||
{ value: "WY", label: "Wyoming" },
|
||||
];
|
||||
|
||||
// Most commonly used countries
|
||||
export const POPULAR_COUNTRIES = [
|
||||
{ value: "United States", label: "United States" },
|
||||
{ value: "United Kingdom", label: "United Kingdom" },
|
||||
{ value: "Canada", label: "Canada" },
|
||||
{ value: "Australia", label: "Australia" },
|
||||
{ value: "Germany", label: "Germany" },
|
||||
{ value: "France", label: "France" },
|
||||
{ value: "India", label: "India" },
|
||||
{ value: "Japan", label: "Japan" },
|
||||
{ value: "Mexico", label: "Mexico" },
|
||||
{ value: "Brazil", label: "Brazil" },
|
||||
];
|
||||
|
||||
// All countries with ISO codes
|
||||
export const ALL_COUNTRIES = [
|
||||
{ value: "Afghanistan", label: "Afghanistan" },
|
||||
{ value: "Albania", label: "Albania" },
|
||||
{ value: "Algeria", label: "Algeria" },
|
||||
{ value: "Andorra", label: "Andorra" },
|
||||
{ value: "Angola", label: "Angola" },
|
||||
{ value: "Antigua and Barbuda", label: "Antigua and Barbuda" },
|
||||
{ value: "Argentina", label: "Argentina" },
|
||||
{ value: "Armenia", label: "Armenia" },
|
||||
{ value: "Australia", label: "Australia" },
|
||||
{ value: "Austria", label: "Austria" },
|
||||
{ value: "Azerbaijan", label: "Azerbaijan" },
|
||||
{ value: "Bahamas", label: "Bahamas" },
|
||||
{ value: "Bahrain", label: "Bahrain" },
|
||||
{ value: "Bangladesh", label: "Bangladesh" },
|
||||
{ value: "Barbados", label: "Barbados" },
|
||||
{ value: "Belarus", label: "Belarus" },
|
||||
{ value: "Belgium", label: "Belgium" },
|
||||
{ value: "Belize", label: "Belize" },
|
||||
{ value: "Benin", label: "Benin" },
|
||||
{ value: "Bhutan", label: "Bhutan" },
|
||||
{ value: "Bolivia", label: "Bolivia" },
|
||||
{ value: "Bosnia and Herzegovina", label: "Bosnia and Herzegovina" },
|
||||
{ value: "Botswana", label: "Botswana" },
|
||||
{ value: "Brazil", label: "Brazil" },
|
||||
{ value: "Brunei", label: "Brunei" },
|
||||
{ value: "Bulgaria", label: "Bulgaria" },
|
||||
{ value: "Burkina Faso", label: "Burkina Faso" },
|
||||
{ value: "Burundi", label: "Burundi" },
|
||||
{ value: "Cabo Verde", label: "Cabo Verde" },
|
||||
{ value: "Cambodia", label: "Cambodia" },
|
||||
{ value: "Cameroon", label: "Cameroon" },
|
||||
{ value: "Canada", label: "Canada" },
|
||||
{ value: "Central African Republic", label: "Central African Republic" },
|
||||
{ value: "Chad", label: "Chad" },
|
||||
{ value: "Chile", label: "Chile" },
|
||||
{ value: "China", label: "China" },
|
||||
{ value: "Colombia", label: "Colombia" },
|
||||
{ value: "Comoros", label: "Comoros" },
|
||||
{ value: "Congo", label: "Congo" },
|
||||
{ value: "Costa Rica", label: "Costa Rica" },
|
||||
{ value: "Croatia", label: "Croatia" },
|
||||
{ value: "Cuba", label: "Cuba" },
|
||||
{ value: "Cyprus", label: "Cyprus" },
|
||||
{ value: "Czech Republic", label: "Czech Republic" },
|
||||
{
|
||||
value: "Democratic Republic of the Congo",
|
||||
label: "Democratic Republic of the Congo",
|
||||
},
|
||||
{ value: "Denmark", label: "Denmark" },
|
||||
{ value: "Djibouti", label: "Djibouti" },
|
||||
{ value: "Dominica", label: "Dominica" },
|
||||
{ value: "Dominican Republic", label: "Dominican Republic" },
|
||||
{ value: "East Timor", label: "East Timor" },
|
||||
{ value: "Ecuador", label: "Ecuador" },
|
||||
{ value: "Egypt", label: "Egypt" },
|
||||
{ value: "El Salvador", label: "El Salvador" },
|
||||
{ value: "Equatorial Guinea", label: "Equatorial Guinea" },
|
||||
{ value: "Eritrea", label: "Eritrea" },
|
||||
{ value: "Estonia", label: "Estonia" },
|
||||
{ value: "Eswatini", label: "Eswatini" },
|
||||
{ value: "Ethiopia", label: "Ethiopia" },
|
||||
{ value: "Fiji", label: "Fiji" },
|
||||
{ value: "Finland", label: "Finland" },
|
||||
{ value: "France", label: "France" },
|
||||
{ value: "Gabon", label: "Gabon" },
|
||||
{ value: "Gambia", label: "Gambia" },
|
||||
{ value: "Georgia", label: "Georgia" },
|
||||
{ value: "Germany", label: "Germany" },
|
||||
{ value: "Ghana", label: "Ghana" },
|
||||
{ value: "Greece", label: "Greece" },
|
||||
{ value: "Grenada", label: "Grenada" },
|
||||
{ value: "Guatemala", label: "Guatemala" },
|
||||
{ value: "Guinea", label: "Guinea" },
|
||||
{ value: "Guinea-Bissau", label: "Guinea-Bissau" },
|
||||
{ value: "Guyana", label: "Guyana" },
|
||||
{ value: "Haiti", label: "Haiti" },
|
||||
{ value: "Honduras", label: "Honduras" },
|
||||
{ value: "Hungary", label: "Hungary" },
|
||||
{ value: "Iceland", label: "Iceland" },
|
||||
{ value: "India", label: "India" },
|
||||
{ value: "Indonesia", label: "Indonesia" },
|
||||
{ value: "Iran", label: "Iran" },
|
||||
{ value: "Iraq", label: "Iraq" },
|
||||
{ value: "Ireland", label: "Ireland" },
|
||||
{ value: "Israel", label: "Israel" },
|
||||
{ value: "Italy", label: "Italy" },
|
||||
{ value: "Ivory Coast", label: "Ivory Coast" },
|
||||
{ value: "Jamaica", label: "Jamaica" },
|
||||
{ value: "Japan", label: "Japan" },
|
||||
{ value: "Jordan", label: "Jordan" },
|
||||
{ value: "Kazakhstan", label: "Kazakhstan" },
|
||||
{ value: "Kenya", label: "Kenya" },
|
||||
{ value: "Kiribati", label: "Kiribati" },
|
||||
{ value: "Kuwait", label: "Kuwait" },
|
||||
{ value: "Kyrgyzstan", label: "Kyrgyzstan" },
|
||||
{ value: "Laos", label: "Laos" },
|
||||
{ value: "Latvia", label: "Latvia" },
|
||||
{ value: "Lebanon", label: "Lebanon" },
|
||||
{ value: "Lesotho", label: "Lesotho" },
|
||||
{ value: "Liberia", label: "Liberia" },
|
||||
{ value: "Libya", label: "Libya" },
|
||||
{ value: "Liechtenstein", label: "Liechtenstein" },
|
||||
{ value: "Lithuania", label: "Lithuania" },
|
||||
{ value: "Luxembourg", label: "Luxembourg" },
|
||||
{ value: "Madagascar", label: "Madagascar" },
|
||||
{ value: "Malawi", label: "Malawi" },
|
||||
{ value: "Malaysia", label: "Malaysia" },
|
||||
{ value: "Maldives", label: "Maldives" },
|
||||
{ value: "Mali", label: "Mali" },
|
||||
{ value: "Malta", label: "Malta" },
|
||||
{ value: "Marshall Islands", label: "Marshall Islands" },
|
||||
{ value: "Mauritania", label: "Mauritania" },
|
||||
{ value: "Mauritius", label: "Mauritius" },
|
||||
{ value: "Mexico", label: "Mexico" },
|
||||
{ value: "Micronesia", label: "Micronesia" },
|
||||
{ value: "Moldova", label: "Moldova" },
|
||||
{ value: "Monaco", label: "Monaco" },
|
||||
{ value: "Mongolia", label: "Mongolia" },
|
||||
{ value: "Montenegro", label: "Montenegro" },
|
||||
{ value: "Morocco", label: "Morocco" },
|
||||
{ value: "Mozambique", label: "Mozambique" },
|
||||
{ value: "Myanmar", label: "Myanmar" },
|
||||
{ value: "Namibia", label: "Namibia" },
|
||||
{ value: "Nauru", label: "Nauru" },
|
||||
{ value: "Nepal", label: "Nepal" },
|
||||
{ value: "Netherlands", label: "Netherlands" },
|
||||
{ value: "New Zealand", label: "New Zealand" },
|
||||
{ value: "Nicaragua", label: "Nicaragua" },
|
||||
{ value: "Niger", label: "Niger" },
|
||||
{ value: "Nigeria", label: "Nigeria" },
|
||||
{ value: "North Korea", label: "North Korea" },
|
||||
{ value: "North Macedonia", label: "North Macedonia" },
|
||||
{ value: "Norway", label: "Norway" },
|
||||
{ value: "Oman", label: "Oman" },
|
||||
{ value: "Pakistan", label: "Pakistan" },
|
||||
{ value: "Palau", label: "Palau" },
|
||||
{ value: "Palestine", label: "Palestine" },
|
||||
{ value: "Panama", label: "Panama" },
|
||||
{ value: "Papua New Guinea", label: "Papua New Guinea" },
|
||||
{ value: "Paraguay", label: "Paraguay" },
|
||||
{ value: "Peru", label: "Peru" },
|
||||
{ value: "Philippines", label: "Philippines" },
|
||||
{ value: "Poland", label: "Poland" },
|
||||
{ value: "Portugal", label: "Portugal" },
|
||||
{ value: "Qatar", label: "Qatar" },
|
||||
{ value: "Romania", label: "Romania" },
|
||||
{ value: "Russia", label: "Russia" },
|
||||
{ value: "Rwanda", label: "Rwanda" },
|
||||
{ value: "Saint Kitts and Nevis", label: "Saint Kitts and Nevis" },
|
||||
{ value: "Saint Lucia", label: "Saint Lucia" },
|
||||
{
|
||||
value: "Saint Vincent and the Grenadines",
|
||||
label: "Saint Vincent and the Grenadines",
|
||||
},
|
||||
{ value: "Samoa", label: "Samoa" },
|
||||
{ value: "San Marino", label: "San Marino" },
|
||||
{ value: "Sao Tome and Principe", label: "Sao Tome and Principe" },
|
||||
{ value: "Saudi Arabia", label: "Saudi Arabia" },
|
||||
{ value: "Senegal", label: "Senegal" },
|
||||
{ value: "Serbia", label: "Serbia" },
|
||||
{ value: "Seychelles", label: "Seychelles" },
|
||||
{ value: "Sierra Leone", label: "Sierra Leone" },
|
||||
{ value: "Singapore", label: "Singapore" },
|
||||
{ value: "Slovakia", label: "Slovakia" },
|
||||
{ value: "Slovenia", label: "Slovenia" },
|
||||
{ value: "Solomon Islands", label: "Solomon Islands" },
|
||||
{ value: "Somalia", label: "Somalia" },
|
||||
{ value: "South Africa", label: "South Africa" },
|
||||
{ value: "South Korea", label: "South Korea" },
|
||||
{ value: "South Sudan", label: "South Sudan" },
|
||||
{ value: "Spain", label: "Spain" },
|
||||
{ value: "Sri Lanka", label: "Sri Lanka" },
|
||||
{ value: "Sudan", label: "Sudan" },
|
||||
{ value: "Suriname", label: "Suriname" },
|
||||
{ value: "Sweden", label: "Sweden" },
|
||||
{ value: "Switzerland", label: "Switzerland" },
|
||||
{ value: "Syria", label: "Syria" },
|
||||
{ value: "Taiwan", label: "Taiwan" },
|
||||
{ value: "Tajikistan", label: "Tajikistan" },
|
||||
{ value: "Tanzania", label: "Tanzania" },
|
||||
{ value: "Thailand", label: "Thailand" },
|
||||
{ value: "Togo", label: "Togo" },
|
||||
{ value: "Tonga", label: "Tonga" },
|
||||
{ value: "Trinidad and Tobago", label: "Trinidad and Tobago" },
|
||||
{ value: "Tunisia", label: "Tunisia" },
|
||||
{ value: "Turkey", label: "Turkey" },
|
||||
{ value: "Turkmenistan", label: "Turkmenistan" },
|
||||
{ value: "Tuvalu", label: "Tuvalu" },
|
||||
{ value: "Uganda", label: "Uganda" },
|
||||
{ value: "Ukraine", label: "Ukraine" },
|
||||
{ value: "United Arab Emirates", label: "United Arab Emirates" },
|
||||
{ value: "United Kingdom", label: "United Kingdom" },
|
||||
{ value: "United States", label: "United States" },
|
||||
{ value: "Uruguay", label: "Uruguay" },
|
||||
{ value: "Uzbekistan", label: "Uzbekistan" },
|
||||
{ value: "Vanuatu", label: "Vanuatu" },
|
||||
{ value: "Vatican City", label: "Vatican City" },
|
||||
{ value: "Venezuela", label: "Venezuela" },
|
||||
{ value: "Vietnam", label: "Vietnam" },
|
||||
{ value: "Yemen", label: "Yemen" },
|
||||
{ value: "Zambia", label: "Zambia" },
|
||||
{ value: "Zimbabwe", label: "Zimbabwe" },
|
||||
];
|
||||
|
||||
// Phone number formatting
|
||||
export function formatPhoneNumber(value: string): string {
|
||||
// Remove all non-numeric characters
|
||||
const phoneNumber = value.replace(/\D/g, "");
|
||||
|
||||
// Format as US phone number
|
||||
if (phoneNumber.length <= 3) {
|
||||
return phoneNumber;
|
||||
} else if (phoneNumber.length <= 6) {
|
||||
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3)}`;
|
||||
} else if (phoneNumber.length <= 10) {
|
||||
return `(${phoneNumber.slice(0, 3)}) ${phoneNumber.slice(3, 6)}-${phoneNumber.slice(6, 10)}`;
|
||||
} else {
|
||||
// Handle international numbers
|
||||
return `+${phoneNumber.slice(0, phoneNumber.length - 10)} (${phoneNumber.slice(-10, -7)}) ${phoneNumber.slice(-7, -4)}-${phoneNumber.slice(-4)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Email validation
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// URL formatting
|
||||
export function formatWebsiteUrl(url: string): string {
|
||||
if (!url) return "";
|
||||
|
||||
// If URL doesn't start with http:// or https://, add https://
|
||||
if (!/^https?:\/\//i.exec(url)) {
|
||||
return `https://${url}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Postal code formatting
|
||||
export function formatPostalCode(
|
||||
value: string,
|
||||
country = "United States",
|
||||
): string {
|
||||
if (country === "United States") {
|
||||
// Format as US ZIP code (12345 or 12345-6789)
|
||||
const digits = value.replace(/\D/g, "");
|
||||
if (digits.length <= 5) {
|
||||
return digits;
|
||||
} else {
|
||||
return `${digits.slice(0, 5)}-${digits.slice(5, 9)}`;
|
||||
}
|
||||
} else if (country === "Canada") {
|
||||
// Format as Canadian postal code (A1A 1A1)
|
||||
const cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
if (cleaned.length <= 3) {
|
||||
return cleaned;
|
||||
} else {
|
||||
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Return as-is for other countries
|
||||
return value;
|
||||
}
|
||||
|
||||
// Tax ID formatting
|
||||
export function formatTaxId(value: string, type = "EIN"): string {
|
||||
const digits = value.replace(/\D/g, "");
|
||||
|
||||
if (type === "EIN") {
|
||||
// Format as XX-XXXXXXX
|
||||
if (digits.length <= 2) {
|
||||
return digits;
|
||||
} else {
|
||||
return `${digits.slice(0, 2)}-${digits.slice(2, 9)}`;
|
||||
}
|
||||
} else if (type === "SSN") {
|
||||
// Format as XXX-XX-XXXX
|
||||
if (digits.length <= 3) {
|
||||
return digits;
|
||||
} else if (digits.length <= 5) {
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||
} else {
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 9)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Form validation messages
|
||||
export const VALIDATION_MESSAGES = {
|
||||
required: "This field is required",
|
||||
email: "Please enter a valid email address",
|
||||
phone: "Please enter a valid phone number",
|
||||
url: "Please enter a valid URL",
|
||||
postalCode: "Please enter a valid postal code",
|
||||
taxId: "Please enter a valid tax ID",
|
||||
};
|
||||
|
||||
// Form field placeholders
|
||||
export const PLACEHOLDERS = {
|
||||
name: "Enter name",
|
||||
email: "email@example.com",
|
||||
phone: "(555) 123-4567",
|
||||
addressLine1: "123 Main Street",
|
||||
addressLine2: "Suite 100",
|
||||
city: "San Francisco",
|
||||
postalCode: "12345",
|
||||
website: "www.example.com",
|
||||
taxId: "12-3456789",
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export function getGravatarUrl(email: string, size = 200) {
|
||||
const trimmedEmail = email.trim().toLowerCase();
|
||||
const hash = createHash("sha256").update(trimmedEmail).digest("hex");
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=mp`;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export const CSV_TEMPLATE_FILENAME = "acme-january-template.csv";
|
||||
export const JSON_TEMPLATE_FILENAME = "invoice-import-template.json";
|
||||
|
||||
/** Matches parseInvoiceCSV column expectations (date, item/description, quantity, rate). */
|
||||
export const CSV_TEMPLATE = `date,item,description,quantity,rate
|
||||
2024-01-15,,API development,8,125.00
|
||||
2024-01-16,Design,Design review and feedback,2,125.00
|
||||
1/17/24,,Documentation,4,125.00`;
|
||||
|
||||
/** Matches parseInvoiceJSON shape (client, issueDate, dueDate, items). */
|
||||
export const JSON_TEMPLATE = JSON.stringify(
|
||||
{
|
||||
invoices: [
|
||||
{
|
||||
name: "January Services",
|
||||
issueDate: "2024-01-31",
|
||||
dueDate: "2024-03-01",
|
||||
client: {
|
||||
name: "Acme Corp",
|
||||
email: "billing@acme.com",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
date: "2024-01-15",
|
||||
description: "API development",
|
||||
quantity: 8,
|
||||
rate: 125,
|
||||
},
|
||||
{
|
||||
date: "2024-01-16",
|
||||
item: "Design",
|
||||
description: "Design review",
|
||||
quantity: 2,
|
||||
rate: 125,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
export function downloadImportTemplate(
|
||||
content: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function downloadCsvTemplate() {
|
||||
downloadImportTemplate(CSV_TEMPLATE, CSV_TEMPLATE_FILENAME, "text/csv");
|
||||
}
|
||||
|
||||
export function downloadJsonTemplate() {
|
||||
downloadImportTemplate(
|
||||
JSON_TEMPLATE,
|
||||
JSON_TEMPLATE_FILENAME,
|
||||
"application/json",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
export type ImportFormat = "csv" | "json";
|
||||
|
||||
export interface ImportItem {
|
||||
date?: Date;
|
||||
description: string;
|
||||
quantity: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export interface ImportClientRef {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface ImportInvoice {
|
||||
name: string;
|
||||
issueDate?: Date;
|
||||
dueDate?: Date;
|
||||
client?: ImportClientRef;
|
||||
clientId?: string;
|
||||
items: ImportItem[];
|
||||
sourceFile?: string;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const COLUMN_ALIASES: Record<string, string[]> = {
|
||||
date: ["date", "item date", "work date", "service date"],
|
||||
item: ["item", "title", "name", "task"],
|
||||
description: ["description", "desc", "details", "work", "notes"],
|
||||
quantity: ["quantity", "qty", "hours", "hour", "units", "amount hours"],
|
||||
rate: ["rate", "hourly rate", "price", "unit price", "unit_rate"],
|
||||
};
|
||||
|
||||
function normalizeHeader(header: string): string {
|
||||
return header.trim().toLowerCase().replace(/[_-]+/g, " ");
|
||||
}
|
||||
|
||||
function resolveColumnIndex(
|
||||
headers: string[],
|
||||
field: keyof typeof COLUMN_ALIASES,
|
||||
): number {
|
||||
const aliases = COLUMN_ALIASES[field] ?? [];
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
const normalized = normalizeHeader(headers[i] ?? "");
|
||||
if (aliases.includes(normalized)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function parseCSVLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
const char = line[i];
|
||||
const nextChar = line[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
current += '"';
|
||||
i += 2;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
i++;
|
||||
}
|
||||
} else if (char === "," && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = "";
|
||||
i++;
|
||||
} else {
|
||||
current += char;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseFlexibleDate(dateStr: string): Date | undefined {
|
||||
const trimmed = dateStr.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
// ISO date (YYYY-MM-DD)
|
||||
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed);
|
||||
if (isoMatch) {
|
||||
const d = new Date(trimmed);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
|
||||
// M/DD/YY or M/DD/YYYY
|
||||
const slashParts = trimmed.split("/");
|
||||
if (slashParts.length === 3) {
|
||||
const month = parseInt(slashParts[0] ?? "1", 10) - 1;
|
||||
const day = parseInt(slashParts[1] ?? "1", 10);
|
||||
let year = parseInt(slashParts[2] ?? "2000", 10);
|
||||
if (year < 100) year += 2000;
|
||||
const d = new Date(year, month, day);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
|
||||
const d = new Date(trimmed);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseNumber(value: string): number {
|
||||
const cleaned = value.replace(/[$,\s]/g, "");
|
||||
const n = parseFloat(cleaned);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function stripExtension(filename: string): string {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
function buildItemDescription(item: string, description: string): string {
|
||||
const parts = [item.trim(), description.trim()].filter(Boolean);
|
||||
return parts.join(" — ") || "Imported item";
|
||||
}
|
||||
|
||||
function deriveIssueDate(items: ImportItem[], fallback?: Date): Date {
|
||||
const itemDates = items
|
||||
.map((i) => i.date)
|
||||
.filter((d): d is Date => d instanceof Date && !isNaN(d.getTime()));
|
||||
if (itemDates.length > 0) {
|
||||
return new Date(Math.max(...itemDates.map((d) => d.getTime())));
|
||||
}
|
||||
return fallback ?? new Date();
|
||||
}
|
||||
|
||||
function defaultDueDate(issueDate: Date): Date {
|
||||
const due = new Date(issueDate);
|
||||
due.setDate(due.getDate() + 30);
|
||||
return due;
|
||||
}
|
||||
|
||||
export function parseInvoiceCSV(
|
||||
csvText: string,
|
||||
filename: string,
|
||||
): ImportInvoice {
|
||||
const errors: string[] = [];
|
||||
const lines = csvText.split(/\r?\n/).filter((l) => l.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return {
|
||||
name: stripExtension(filename),
|
||||
items: [],
|
||||
sourceFile: filename,
|
||||
errors: ["File is empty"],
|
||||
};
|
||||
}
|
||||
|
||||
const headers = parseCSVLine(lines[0] ?? "");
|
||||
const dateIdx = resolveColumnIndex(headers, "date");
|
||||
const itemIdx = resolveColumnIndex(headers, "item");
|
||||
const descIdx = resolveColumnIndex(headers, "description");
|
||||
const qtyIdx = resolveColumnIndex(headers, "quantity");
|
||||
const rateIdx = resolveColumnIndex(headers, "rate");
|
||||
|
||||
if (descIdx === -1 && itemIdx === -1) {
|
||||
errors.push(
|
||||
'Missing description column (expected "description" or "item")',
|
||||
);
|
||||
}
|
||||
if (qtyIdx === -1) {
|
||||
errors.push('Missing quantity column (expected "quantity" or "hours")');
|
||||
}
|
||||
if (rateIdx === -1) {
|
||||
errors.push('Missing rate column (expected "rate" or "price")');
|
||||
}
|
||||
|
||||
const items: ImportItem[] = [];
|
||||
|
||||
for (let rowIdx = 1; rowIdx < lines.length; rowIdx++) {
|
||||
const values = parseCSVLine(lines[rowIdx] ?? "");
|
||||
if (values.every((v) => !v.trim())) continue;
|
||||
|
||||
const itemText = itemIdx >= 0 ? (values[itemIdx] ?? "") : "";
|
||||
const descText = descIdx >= 0 ? (values[descIdx] ?? "") : "";
|
||||
const description = buildItemDescription(itemText, descText);
|
||||
|
||||
const quantity = qtyIdx >= 0 ? parseNumber(values[qtyIdx] ?? "0") : 0;
|
||||
const rate = rateIdx >= 0 ? parseNumber(values[rateIdx] ?? "0") : 0;
|
||||
|
||||
if (!description || description === "Imported item") {
|
||||
if (!itemText && !descText) continue;
|
||||
}
|
||||
|
||||
if (quantity <= 0) {
|
||||
errors.push(`Row ${rowIdx + 1}: quantity must be greater than 0`);
|
||||
continue;
|
||||
}
|
||||
if (rate <= 0) {
|
||||
errors.push(`Row ${rowIdx + 1}: rate must be greater than 0`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let date: Date | undefined;
|
||||
if (dateIdx >= 0) {
|
||||
const rawDate = values[dateIdx] ?? "";
|
||||
if (rawDate.trim()) {
|
||||
date = parseFlexibleDate(rawDate);
|
||||
if (!date) {
|
||||
errors.push(`Row ${rowIdx + 1}: invalid date "${rawDate}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.push({ date, description, quantity, rate });
|
||||
}
|
||||
|
||||
const issueDate = deriveIssueDate(items);
|
||||
|
||||
return {
|
||||
name: stripExtension(filename),
|
||||
issueDate,
|
||||
dueDate: defaultDueDate(issueDate),
|
||||
items,
|
||||
sourceFile: filename,
|
||||
errors:
|
||||
items.length === 0 && errors.length === 0
|
||||
? ["No valid line items found"]
|
||||
: errors,
|
||||
};
|
||||
}
|
||||
|
||||
interface JsonInvoiceItem {
|
||||
date?: string;
|
||||
description?: string;
|
||||
item?: string;
|
||||
quantity?: number;
|
||||
hours?: number;
|
||||
rate?: number;
|
||||
}
|
||||
|
||||
interface JsonInvoice {
|
||||
name?: string;
|
||||
invoiceNumber?: string;
|
||||
issueDate?: string;
|
||||
dueDate?: string;
|
||||
client?: { name?: string; email?: string };
|
||||
clientName?: string;
|
||||
items?: JsonInvoiceItem[];
|
||||
}
|
||||
|
||||
function normalizeJsonInvoice(raw: JsonInvoice, index: number): ImportInvoice {
|
||||
const errors: string[] = [];
|
||||
const name = raw.name ?? raw.invoiceNumber ?? `Imported Invoice ${index + 1}`;
|
||||
|
||||
const clientName = raw.client?.name ?? raw.clientName;
|
||||
const clientEmail = raw.client?.email;
|
||||
|
||||
const items: ImportItem[] = (raw.items ?? []).map((item, itemIdx) => {
|
||||
const description = buildItemDescription(
|
||||
item.item ?? "",
|
||||
item.description ?? "",
|
||||
);
|
||||
const quantity = item.quantity ?? item.hours ?? 0;
|
||||
const rate = item.rate ?? 0;
|
||||
|
||||
if (!description || description === "Imported item") {
|
||||
errors.push(`Invoice "${name}" item ${itemIdx + 1}: description required`);
|
||||
}
|
||||
if (quantity <= 0) {
|
||||
errors.push(
|
||||
`Invoice "${name}" item ${itemIdx + 1}: quantity must be greater than 0`,
|
||||
);
|
||||
}
|
||||
if (rate <= 0) {
|
||||
errors.push(
|
||||
`Invoice "${name}" item ${itemIdx + 1}: rate must be greater than 0`,
|
||||
);
|
||||
}
|
||||
|
||||
let date: Date | undefined;
|
||||
if (item.date) {
|
||||
date = parseFlexibleDate(item.date);
|
||||
if (!date) {
|
||||
errors.push(
|
||||
`Invoice "${name}" item ${itemIdx + 1}: invalid date "${item.date}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { date, description, quantity, rate };
|
||||
});
|
||||
|
||||
let issueDate: Date | undefined;
|
||||
if (raw.issueDate) {
|
||||
issueDate = parseFlexibleDate(raw.issueDate);
|
||||
if (!issueDate) {
|
||||
errors.push(`Invoice "${name}": invalid issue date "${raw.issueDate}"`);
|
||||
}
|
||||
}
|
||||
|
||||
let dueDate: Date | undefined;
|
||||
if (raw.dueDate) {
|
||||
dueDate = parseFlexibleDate(raw.dueDate);
|
||||
if (!dueDate) {
|
||||
errors.push(`Invoice "${name}": invalid due date "${raw.dueDate}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedIssue = issueDate ?? deriveIssueDate(items);
|
||||
const resolvedDue = dueDate ?? defaultDueDate(resolvedIssue);
|
||||
|
||||
if (items.length === 0) {
|
||||
errors.push(`Invoice "${name}": at least one item is required`);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
issueDate: resolvedIssue,
|
||||
dueDate: resolvedDue,
|
||||
client:
|
||||
clientName || clientEmail
|
||||
? { name: clientName, email: clientEmail }
|
||||
: undefined,
|
||||
items,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseInvoiceJSON(jsonText: string): ImportInvoice[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch {
|
||||
return [
|
||||
{
|
||||
name: "JSON Import",
|
||||
items: [],
|
||||
errors: ["Invalid JSON format"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let rawInvoices: JsonInvoice[] = [];
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
rawInvoices = parsed as JsonInvoice[];
|
||||
} else if (parsed && typeof parsed === "object") {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (Array.isArray(obj.invoices)) {
|
||||
rawInvoices = obj.invoices as JsonInvoice[];
|
||||
} else if (obj.items || obj.name || obj.invoiceNumber) {
|
||||
rawInvoices = [obj];
|
||||
}
|
||||
}
|
||||
|
||||
if (rawInvoices.length === 0) {
|
||||
return [
|
||||
{
|
||||
name: "JSON Import",
|
||||
items: [],
|
||||
errors: ['No invoices found (expected { "invoices": [...] } or an array)'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return rawInvoices.map((inv, idx) => normalizeJsonInvoice(inv, idx));
|
||||
}
|
||||
|
||||
export function detectImportFormat(filename: string): ImportFormat {
|
||||
return filename.toLowerCase().endsWith(".json") ? "json" : "csv";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type LineItemBillingType = "hourly" | "fixed";
|
||||
|
||||
export function isFixedLineItem(hours: number): boolean {
|
||||
return hours === 0;
|
||||
}
|
||||
|
||||
export function getLineItemBillingType(hours: number): LineItemBillingType {
|
||||
return isFixedLineItem(hours) ? "fixed" : "hourly";
|
||||
}
|
||||
|
||||
export function calculateLineItemAmount(hours: number, rate: number): number {
|
||||
return isFixedLineItem(hours) ? rate : hours * rate;
|
||||
}
|
||||
|
||||
export function formatLineItemDetail(
|
||||
hours: number,
|
||||
rate: number,
|
||||
formatCurrency: (amount: number) => string,
|
||||
): string {
|
||||
if (isFixedLineItem(hours)) {
|
||||
return "Fixed amount";
|
||||
}
|
||||
return `${hours}h @ ${formatCurrency(rate)}/hr`;
|
||||
}
|
||||
|
||||
export function applyBillingTypeChange(
|
||||
billingType: LineItemBillingType,
|
||||
current: { hours: number; rate: number },
|
||||
): { hours: number; rate: number; amount: number } {
|
||||
if (billingType === "fixed") {
|
||||
const amount = calculateLineItemAmount(current.hours, current.rate);
|
||||
return { hours: 0, rate: amount, amount };
|
||||
}
|
||||
|
||||
const hours = current.hours > 0 ? current.hours : 1;
|
||||
const amount = calculateLineItemAmount(hours, current.rate);
|
||||
return { hours, rate: current.rate, amount };
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
StoredInvoiceStatus,
|
||||
EffectiveInvoiceStatus,
|
||||
} from "~/types/invoice";
|
||||
|
||||
// Types are now imported from ~/types/invoice
|
||||
|
||||
/**
|
||||
* Calculate the effective status of an invoice including overdue computation
|
||||
*/
|
||||
export function getEffectiveInvoiceStatus(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): EffectiveInvoiceStatus {
|
||||
// If already paid, status is always paid regardless of due date
|
||||
if (storedStatus === "paid") {
|
||||
return "paid";
|
||||
}
|
||||
|
||||
// If draft, status is always draft
|
||||
if (storedStatus === "draft") {
|
||||
return "draft";
|
||||
}
|
||||
|
||||
// For sent invoices, check if overdue
|
||||
if (storedStatus === "sent") {
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
// Set both dates to start of day for accurate comparison
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
return due < today ? "overdue" : "sent";
|
||||
}
|
||||
|
||||
return storedStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an invoice is overdue
|
||||
*/
|
||||
export function isInvoiceOverdue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): boolean {
|
||||
return getEffectiveInvoiceStatus(storedStatus, dueDate) === "overdue";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days past due (returns 0 if not overdue)
|
||||
*/
|
||||
export function getDaysPastDue(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
): number {
|
||||
if (!isInvoiceOverdue(storedStatus, dueDate)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const due = new Date(dueDate);
|
||||
|
||||
today.setHours(0, 0, 0, 0);
|
||||
due.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffTime = today.getTime() - due.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return Math.max(0, diffDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status configuration for UI display
|
||||
*/
|
||||
export const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
color: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300",
|
||||
description: "Invoice is being prepared",
|
||||
},
|
||||
sent: {
|
||||
label: "Sent",
|
||||
color: "bg-primary/10 text-primary",
|
||||
description: "Invoice sent to client",
|
||||
},
|
||||
paid: {
|
||||
label: "Paid",
|
||||
color: "bg-primary/10 text-primary",
|
||||
description: "Payment received",
|
||||
},
|
||||
overdue: {
|
||||
label: "Overdue",
|
||||
color: "bg-destructive/10 text-destructive",
|
||||
description: "Payment is overdue",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get status configuration for display
|
||||
*/
|
||||
export function getStatusConfig(
|
||||
storedStatus: StoredInvoiceStatus,
|
||||
dueDate: Date | string,
|
||||
) {
|
||||
const effectiveStatus = getEffectiveInvoiceStatus(storedStatus, dueDate);
|
||||
return statusConfig[effectiveStatus];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid status transitions from current stored status
|
||||
*/
|
||||
export function getValidStatusTransitions(
|
||||
currentStatus: StoredInvoiceStatus,
|
||||
): StoredInvoiceStatus[] {
|
||||
switch (currentStatus) {
|
||||
case "draft":
|
||||
return ["sent", "paid"]; // Can send or mark paid directly
|
||||
case "sent":
|
||||
return ["paid", "draft"]; // Can mark paid or revert to draft
|
||||
case "paid":
|
||||
return ["sent"]; // Can revert to sent if needed (rare cases)
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status transition is valid
|
||||
*/
|
||||
export function isValidStatusTransition(
|
||||
from: StoredInvoiceStatus,
|
||||
to: StoredInvoiceStatus,
|
||||
): boolean {
|
||||
const validTransitions = getValidStatusTransitions(from);
|
||||
return validTransitions.includes(to);
|
||||
}
|
||||
@@ -0,0 +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_EMAIL;
|
||||
export const LEGAL_TERMS_EMAIL = LEGAL_EMAIL;
|
||||
export const LEGAL_WEBSITE = getAppUrl();
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Settings,
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
FileText,
|
||||
Receipt,
|
||||
BarChart2,
|
||||
Shield,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavLink {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
title: string;
|
||||
links: NavLink[];
|
||||
}
|
||||
|
||||
export function isNavLinkActive(pathname: string, href: string): boolean {
|
||||
if (href === "/dashboard/entities") {
|
||||
return (
|
||||
pathname === href ||
|
||||
pathname.startsWith("/dashboard/clients") ||
|
||||
pathname.startsWith("/dashboard/businesses")
|
||||
);
|
||||
}
|
||||
if (href === "/dashboard/time-clock") {
|
||||
return pathname === href || pathname.startsWith("/dashboard/time-clock/");
|
||||
}
|
||||
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",
|
||||
links: [
|
||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ name: "Time clock", href: "/dashboard/time-clock", icon: Clock },
|
||||
{ name: "Entities", href: "/dashboard/entities", icon: Users },
|
||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||
{
|
||||
name: "Recurring",
|
||||
href: "/dashboard/invoices/recurring",
|
||||
icon: RefreshCw,
|
||||
},
|
||||
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
||||
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Account",
|
||||
links: [
|
||||
{ name: "Settings", href: "/dashboard/settings", icon: Settings },
|
||||
{
|
||||
name: "Administration",
|
||||
href: "/dashboard/administration",
|
||||
icon: Shield,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,173 @@
|
||||
import "server-only";
|
||||
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import type * as S3ClientModule from "@aws-sdk/client-s3";
|
||||
|
||||
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
|
||||
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
|
||||
|
||||
function isS3Configured(): boolean {
|
||||
return Boolean(
|
||||
process.env.S3_BUCKET &&
|
||||
process.env.S3_ACCESS_KEY &&
|
||||
process.env.S3_SECRET_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
export function getStorageBackend(): "s3" | "local" {
|
||||
return isS3Configured() ? "s3" : "local";
|
||||
}
|
||||
|
||||
type S3Module = typeof S3ClientModule;
|
||||
|
||||
let s3ModulePromise: Promise<S3Module> | null = null;
|
||||
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
||||
let s3DnsHintLogged = false;
|
||||
let s3BareGarageHintLogged = false;
|
||||
|
||||
function shouldForcePathStyle(): boolean {
|
||||
const override = process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase();
|
||||
if (override === "true" || override === "1") return true;
|
||||
if (override === "false" || override === "0") return false;
|
||||
return Boolean(process.env.S3_ENDPOINT);
|
||||
}
|
||||
|
||||
function logBareGarageEndpointHint(): void {
|
||||
if (s3BareGarageHintLogged || process.env.NODE_ENV !== "production") return;
|
||||
const endpoint = process.env.S3_ENDPOINT;
|
||||
if (!endpoint) return;
|
||||
try {
|
||||
const { hostname } = new URL(endpoint);
|
||||
if (hostname !== "garage") return;
|
||||
s3BareGarageHintLogged = true;
|
||||
console.warn(
|
||||
"[object-storage] S3_ENDPOINT hostname is bare 'garage'. " +
|
||||
"That only resolves inside a single Docker Compose stack. " +
|
||||
"Coolify Application + separate Garage compose: set S3_ENDPOINT to " +
|
||||
"SERVICE_URL_GARAGE_3900 (public domain) or http://garage-<resource-uuid>:3900. " +
|
||||
"See docs/COOLIFY.md.",
|
||||
);
|
||||
} catch {
|
||||
// Invalid URL — env validation or S3 client will surface it.
|
||||
}
|
||||
}
|
||||
|
||||
function logS3DnsHint(error: unknown): void {
|
||||
if (s3DnsHintLogged) return;
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOTFOUND" && code !== "EAI_AGAIN") return;
|
||||
s3DnsHintLogged = true;
|
||||
const endpoint = process.env.S3_ENDPOINT ?? "(AWS default)";
|
||||
console.error(
|
||||
`[object-storage] S3 DNS failed (${code}) for endpoint ${endpoint}. ` +
|
||||
"Separate Coolify stacks cannot resolve bare 'garage' — use the internal hostname from the Garage resource UI and enable Connect to Predefined Network on the app. See docs/COOLIFY.md.",
|
||||
);
|
||||
}
|
||||
|
||||
async function withS3Diagnostics<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
logS3DnsHint(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getS3() {
|
||||
s3ModulePromise ??= import("@aws-sdk/client-s3");
|
||||
const mod = await s3ModulePromise;
|
||||
if (!s3Client) {
|
||||
logBareGarageEndpointHint();
|
||||
s3Client = new mod.S3Client({
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||
},
|
||||
// Required for Garage and most S3-compatible endpoints (including HTTPS proxies).
|
||||
forcePathStyle: shouldForcePathStyle(),
|
||||
});
|
||||
}
|
||||
return { client: s3Client, ...mod };
|
||||
}
|
||||
|
||||
function localPathForKey(key: string) {
|
||||
return path.join(LOCAL_RECEIPTS_DIR, key);
|
||||
}
|
||||
|
||||
export async function putObject(
|
||||
key: string,
|
||||
body: Buffer,
|
||||
contentType: string,
|
||||
): Promise<void> {
|
||||
if (isS3Configured()) {
|
||||
const { client, PutObjectCommand } = await getS3();
|
||||
await withS3Diagnostics(() =>
|
||||
client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET!,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = localPathForKey(key);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, body);
|
||||
}
|
||||
|
||||
export async function getObject(key: string): Promise<Buffer> {
|
||||
if (isS3Configured()) {
|
||||
const { client, GetObjectCommand } = await getS3();
|
||||
const response = await withS3Diagnostics(() =>
|
||||
client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET!,
|
||||
Key: key,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const bytes = await response.Body?.transformToByteArray();
|
||||
if (!bytes) {
|
||||
throw new Error("Empty object body");
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
return readFile(localPathForKey(key));
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
if (isS3Configured()) {
|
||||
const { client, DeleteObjectCommand } = await getS3();
|
||||
await withS3Diagnostics(() =>
|
||||
client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: process.env.S3_BUCKET!,
|
||||
Key: key,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(localPathForKey(key));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const RECEIPT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function isAllowedReceiptMime(mimeType: string): boolean {
|
||||
const normalized = mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
||||
return normalized === "application/pdf" || normalized.startsWith("image/");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface ParsedLineItem {
|
||||
description: string;
|
||||
hours: number | null;
|
||||
rate: number | null;
|
||||
}
|
||||
|
||||
export function parseLineItem(input: string): ParsedLineItem {
|
||||
let text = input.trim();
|
||||
let hours: number | null = null;
|
||||
let rate: number | null = null;
|
||||
|
||||
// Extract hours: "3h", "3hr", "3hrs", "3 hours", "3.5hours"
|
||||
const hoursMatch = /(\d+\.?\d*)\s*h(?:ours?|rs?)\b/i.exec(text);
|
||||
if (hoursMatch?.[0] && hoursMatch[1]) {
|
||||
hours = parseFloat(hoursMatch[1]);
|
||||
text = text.replace(hoursMatch[0], " ").trim();
|
||||
}
|
||||
|
||||
// Extract rate: "@120", "@$120", "at 120", "at $120", "$120/hr", "$120ph"
|
||||
const rateMatch = /(?:@\s*\$?|at\s+\$?)(\d+\.?\d*)|(\$\d+\.?\d*)(?:\/h(?:rs?)?|ph)?\b/i.exec(text);
|
||||
if (rateMatch?.[0]) {
|
||||
const rawRate = rateMatch[1] ?? rateMatch[2] ?? "";
|
||||
rate = parseFloat(rawRate.replace("$", ""));
|
||||
text = text.replace(rateMatch[0], " ").trim();
|
||||
}
|
||||
|
||||
const description = text.replace(/\s+/g, " ").replace(/^[\s,]+|[\s,]+$/g, "").trim();
|
||||
|
||||
return { description: description || input.trim(), hours, rate };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Resend } from "resend";
|
||||
import { env } from "~/env";
|
||||
import { APP_EMAIL_DOMAIN } from "~/lib/app-email";
|
||||
import { getAppUrl } from "~/lib/app-url";
|
||||
import { generatePasswordResetEmailTemplate } from "~/lib/email-templates";
|
||||
import {
|
||||
createPasswordResetToken,
|
||||
hashPasswordResetToken,
|
||||
} from "~/lib/reset-token";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
export type PasswordResetResult = {
|
||||
success: boolean;
|
||||
emailSent: boolean;
|
||||
userEmail?: string;
|
||||
};
|
||||
|
||||
export async function sendPasswordResetEmail(input: {
|
||||
userEmail: string;
|
||||
userName?: string;
|
||||
resetToken: string;
|
||||
}): Promise<PasswordResetResult> {
|
||||
if (!env.RESEND_API_KEY) {
|
||||
console.warn(
|
||||
"Password reset requested, but RESEND_API_KEY is not configured.",
|
||||
);
|
||||
return { success: true, emailSent: false, userEmail: input.userEmail };
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = new Resend(env.RESEND_API_KEY);
|
||||
const resetUrl = `${getAppUrl()}/auth/reset-password?token=${input.resetToken}`;
|
||||
const emailTemplate = generatePasswordResetEmailTemplate({
|
||||
userEmail: input.userEmail,
|
||||
userName: input.userName,
|
||||
resetToken: input.resetToken,
|
||||
resetUrl,
|
||||
expiryHours: 1,
|
||||
});
|
||||
const fromDomain = env.RESEND_DOMAIN ?? APP_EMAIL_DOMAIN;
|
||||
|
||||
await resend.emails.send({
|
||||
from: `beenvoice <noreply@${fromDomain}>`,
|
||||
to: input.userEmail,
|
||||
subject: emailTemplate.subject,
|
||||
html: emailTemplate.html,
|
||||
text: emailTemplate.text,
|
||||
});
|
||||
|
||||
return { success: true, emailSent: true, userEmail: input.userEmail };
|
||||
} catch (emailError) {
|
||||
console.error("Failed to send password reset email:", emailError);
|
||||
return { success: true, emailSent: false, userEmail: input.userEmail };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPasswordResetForUser(
|
||||
userId: string,
|
||||
): Promise<PasswordResetResult> {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { id: true, email: true, name: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { success: false, emailSent: false };
|
||||
}
|
||||
|
||||
const resetToken = createPasswordResetToken();
|
||||
const resetTokenHash = hashPasswordResetToken(resetToken);
|
||||
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ resetToken: resetTokenHash, resetTokenExpiry })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return sendPasswordResetEmail({
|
||||
userEmail: user.email,
|
||||
userName: user.name ?? undefined,
|
||||
resetToken,
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Built-in PDF font presets (react-pdf standard fonts, no embedding required). */
|
||||
export const pdfFontFamilyValues = ["sans", "serif", "mono"] as const;
|
||||
|
||||
export const pdfFontFamilySchema = z.enum(pdfFontFamilyValues);
|
||||
|
||||
export type PdfFontFamily = z.infer<typeof pdfFontFamilySchema>;
|
||||
|
||||
export interface ResolvedPdfFonts {
|
||||
regular: string;
|
||||
bold: string;
|
||||
mono: string;
|
||||
monoBold: string;
|
||||
}
|
||||
|
||||
export const pdfFontFamilyOptions: {
|
||||
value: PdfFontFamily;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "sans",
|
||||
label: "Modern",
|
||||
description: "Clean sans-serif (Helvetica).",
|
||||
},
|
||||
{
|
||||
value: "serif",
|
||||
label: "Classic",
|
||||
description: "Traditional serif (Times).",
|
||||
},
|
||||
{
|
||||
value: "mono",
|
||||
label: "Monospace",
|
||||
description: "Fixed-width type (Courier).",
|
||||
},
|
||||
];
|
||||
|
||||
function resolveBodyFonts(family: PdfFontFamily): Pick<ResolvedPdfFonts, "regular" | "bold"> {
|
||||
switch (family) {
|
||||
case "serif":
|
||||
return {
|
||||
regular: "Times-Roman",
|
||||
bold: "Times-Bold",
|
||||
};
|
||||
case "mono":
|
||||
return {
|
||||
regular: "Courier",
|
||||
bold: "Courier-Bold",
|
||||
};
|
||||
case "sans":
|
||||
default:
|
||||
return {
|
||||
regular: "Helvetica",
|
||||
bold: "Helvetica-Bold",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNumericFonts(
|
||||
family: PdfFontFamily,
|
||||
): Pick<ResolvedPdfFonts, "mono" | "monoBold"> {
|
||||
switch (family) {
|
||||
case "serif":
|
||||
return {
|
||||
mono: "Times-Roman",
|
||||
monoBold: "Times-Bold",
|
||||
};
|
||||
case "mono":
|
||||
return {
|
||||
mono: "Courier",
|
||||
monoBold: "Courier-Bold",
|
||||
};
|
||||
case "sans":
|
||||
default:
|
||||
return {
|
||||
mono: "Helvetica",
|
||||
monoBold: "Helvetica-Bold",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePdfFonts(
|
||||
bodyFamily: PdfFontFamily,
|
||||
numericFamily: PdfFontFamily = "mono",
|
||||
): ResolvedPdfFonts {
|
||||
return {
|
||||
...resolveBodyFonts(bodyFamily),
|
||||
...resolveNumericFonts(numericFamily),
|
||||
};
|
||||
}
|
||||
|
||||
export function pdfFontCacheKey(
|
||||
bodyFamily: PdfFontFamily,
|
||||
numericFamily: PdfFontFamily,
|
||||
): string {
|
||||
return `${bodyFamily}:${numericFamily}`;
|
||||
}
|
||||
|
||||
export function isPdfFontFamily(value: unknown): value is PdfFontFamily {
|
||||
return pdfFontFamilySchema.safeParse(value).success;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Pluralization rules for common entities in the app
|
||||
*/
|
||||
const PLURALIZATION_RULES: Record<
|
||||
string,
|
||||
{ singular: string; plural: string }
|
||||
> = {
|
||||
business: { singular: "Business", plural: "Businesses" },
|
||||
client: { singular: "Client", plural: "Clients" },
|
||||
invoice: { singular: "Invoice", plural: "Invoices" },
|
||||
setting: { singular: "Setting", plural: "Settings" },
|
||||
user: { singular: "User", plural: "Users" },
|
||||
payment: { singular: "Payment", plural: "Payments" },
|
||||
item: { singular: "Item", plural: "Items" },
|
||||
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" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the plural form of a word
|
||||
*/
|
||||
export function pluralize(word: string, count?: number): string {
|
||||
// If count is provided and is 1, return singular
|
||||
if (count === 1) {
|
||||
return word;
|
||||
}
|
||||
|
||||
const lowerWord = word.toLowerCase();
|
||||
|
||||
// Check if we have a specific rule for this word
|
||||
if (PLURALIZATION_RULES[lowerWord]) {
|
||||
return PLURALIZATION_RULES[lowerWord].plural;
|
||||
}
|
||||
|
||||
// Apply general pluralization rules
|
||||
// Words ending in s, ss, sh, ch, x, z
|
||||
if (/(?:s|ss|sh|ch|x|z)$/i.test(word)) {
|
||||
return word + "es";
|
||||
}
|
||||
|
||||
// Words ending in consonant + y
|
||||
if (/[^aeiou]y$/i.test(word)) {
|
||||
return word.slice(0, -1) + "ies";
|
||||
}
|
||||
|
||||
// Words ending in f or fe
|
||||
if (/(?:f|fe)$/i.test(word)) {
|
||||
return word.replace(/(?:f|fe)$/i, "ves");
|
||||
}
|
||||
|
||||
// Default: just add 's'
|
||||
return word + "s";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singular form of a word
|
||||
*/
|
||||
export function singularize(word: string): string {
|
||||
const lowerWord = word.toLowerCase();
|
||||
|
||||
// Check if we have a specific rule for this word (search by plural)
|
||||
const rule = Object.values(PLURALIZATION_RULES).find(
|
||||
(r) => r.plural.toLowerCase() === lowerWord,
|
||||
);
|
||||
|
||||
if (rule) {
|
||||
return rule.singular;
|
||||
}
|
||||
|
||||
// Apply general singularization rules
|
||||
// Words ending in ies
|
||||
if (/ies$/i.test(word)) {
|
||||
return word.slice(0, -3) + "y";
|
||||
}
|
||||
|
||||
// Words ending in es
|
||||
if (/(?:s|ss|sh|ch|x|z)es$/i.test(word)) {
|
||||
return word.slice(0, -2);
|
||||
}
|
||||
|
||||
// Words ending in ves
|
||||
if (/ves$/i.test(word)) {
|
||||
return word.slice(0, -3) + "f";
|
||||
}
|
||||
|
||||
// Words ending in s
|
||||
if (/s$/i.test(word) && word.length > 1) {
|
||||
return word.slice(0, -1);
|
||||
}
|
||||
|
||||
// Default: return as is
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize the first letter of a word
|
||||
*/
|
||||
export function capitalize(word: string): string {
|
||||
if (!word) return word;
|
||||
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a properly formatted label for a route segment
|
||||
*/
|
||||
export function getRouteLabel(segment: string, isPlural = true): string {
|
||||
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;
|
||||
}
|
||||
|
||||
const singularForm = singularize(segment);
|
||||
const singularRule = PLURALIZATION_RULES[singularForm.toLowerCase()];
|
||||
if (singularRule) {
|
||||
return isPlural ? singularRule.plural : singularRule.singular;
|
||||
}
|
||||
|
||||
const capitalized = capitalize(segment);
|
||||
if (isPlural && /s$/i.test(segment)) {
|
||||
return capitalized;
|
||||
}
|
||||
|
||||
return isPlural ? pluralize(capitalized) : capitalize(singularForm);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Routes that do not require authentication or server session lookups. */
|
||||
export const PUBLIC_ROUTES = [
|
||||
"/",
|
||||
"/auth/signin",
|
||||
"/auth/register",
|
||||
"/auth/forgot-password",
|
||||
"/auth/reset-password",
|
||||
"/privacy",
|
||||
"/terms",
|
||||
] as const;
|
||||
|
||||
/** Path prefixes treated as public (e.g. shareable invoice links). */
|
||||
export const PUBLIC_ROUTE_PREFIXES = ["/i/"] as const;
|
||||
|
||||
export function isPublicRoute(pathname: string): boolean {
|
||||
if ((PUBLIC_ROUTES as readonly string[]).includes(pathname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
type RateLimitRule = {
|
||||
windowMs: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
type RateLimitRecord = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, RateLimitRecord>();
|
||||
|
||||
function clientIp(request: NextRequest) {
|
||||
return (
|
||||
request.headers.get("cf-connecting-ip") ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
export function hashRateLimitPart(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function rateLimitKey(request: NextRequest, scope: string, subject?: string) {
|
||||
const parts = [scope, clientIp(request)];
|
||||
if (subject) parts.push(hashRateLimitPart(subject.toLowerCase().trim()));
|
||||
return parts.join(":");
|
||||
}
|
||||
|
||||
function retryAfterSeconds(resetAt: number) {
|
||||
return Math.max(1, Math.ceil((resetAt - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
export function checkRateLimit(key: string, rule: RateLimitRule) {
|
||||
const now = Date.now();
|
||||
const existing = buckets.get(key);
|
||||
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + rule.windowMs });
|
||||
return { allowed: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
if (existing.count <= rule.max) {
|
||||
return { allowed: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
return { allowed: false, retryAfter: retryAfterSeconds(existing.resetAt) };
|
||||
}
|
||||
|
||||
export function rateLimitResponse(retryAfter: number) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many attempts. Please wait and try again." },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": String(retryAfter),
|
||||
"X-RateLimit-Retry-After": String(retryAfter),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function requireRateLimit(key: string, rule: RateLimitRule) {
|
||||
const result = checkRateLimit(key, rule);
|
||||
return result.allowed ? null : rateLimitResponse(result.retryAfter);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type ReceiptParseResult = {
|
||||
amount: number | null;
|
||||
date: Date | null;
|
||||
vendor: string | null;
|
||||
rawLines: string[];
|
||||
};
|
||||
|
||||
const AMOUNT_PATTERNS = [
|
||||
/(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i,
|
||||
/\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i,
|
||||
/(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i,
|
||||
];
|
||||
|
||||
const DATE_PATTERNS = [
|
||||
/(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/,
|
||||
/(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/,
|
||||
];
|
||||
|
||||
function parseAmount(text: string): number | null {
|
||||
for (const pattern of AMOUNT_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (!match?.[1]) continue;
|
||||
const value = Number(match[1].replace(/,/g, ""));
|
||||
if (Number.isFinite(value) && value > 0) return value;
|
||||
}
|
||||
|
||||
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
|
||||
.map((m) => Number(m[1]!.replace(/,/g, "")))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
|
||||
return amounts.length > 0 ? Math.max(...amounts) : null;
|
||||
}
|
||||
|
||||
function parseDate(text: string): Date | null {
|
||||
for (const pattern of DATE_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (!match?.[1]) continue;
|
||||
const parsed = new Date(match[1]);
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseVendor(lines: string[]): string | null {
|
||||
const candidate = lines.find((line) => line.trim().length >= 3);
|
||||
return candidate?.trim().slice(0, 120) ?? null;
|
||||
}
|
||||
|
||||
/** Heuristic receipt field extraction from OCR or pasted text. */
|
||||
export function parseReceiptText(text: string): ReceiptParseResult {
|
||||
const normalized = text.replace(/\r/g, "\n").trim();
|
||||
const rawLines = normalized
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
amount: parseAmount(normalized),
|
||||
date: parseDate(normalized),
|
||||
vendor: parseVendor(rawLines),
|
||||
rawLines,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export function createPasswordResetToken() {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
export function hashPasswordResetToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const FALLBACK_CALLBACK_PATH = "/dashboard";
|
||||
|
||||
export function safeCallbackPath(value: string | null | undefined) {
|
||||
if (!value) return FALLBACK_CALLBACK_PATH;
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
!trimmed.startsWith("/") ||
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.includes("\\") ||
|
||||
/[\u0000-\u001f\u007f]/.test(trimmed)
|
||||
) {
|
||||
return FALLBACK_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed, "https://beenvoice.local");
|
||||
if (url.origin !== "https://beenvoice.local") return FALLBACK_CALLBACK_PATH;
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
return FALLBACK_CALLBACK_PATH;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { sessions } from "~/server/db/schema";
|
||||
|
||||
export async function revokeUserSessions(userId: string, exceptToken?: string | null) {
|
||||
const condition = exceptToken
|
||||
? and(eq(sessions.userId, userId), ne(sessions.token, exceptToken))
|
||||
: eq(sessions.userId, userId);
|
||||
|
||||
await db.delete(sessions).where(condition);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* Lightweight defense-in-depth pass over uploaded SVG markup before it is
|
||||
* stored. Strips executable content (scripts, event handlers, external
|
||||
* references) so a malicious SVG can't run script if it's ever rendered
|
||||
* inline (dangerouslySetInnerHTML) rather than via <img src>. Not a full
|
||||
* parser — good enough for a self-uploaded logo, not a substitute for
|
||||
* treating SVG as active content from an untrusted source.
|
||||
*/
|
||||
export function sanitizeSvg(input: string): string {
|
||||
let svg = input;
|
||||
|
||||
// Strip <script>...</script> blocks and self-closing <script/> tags.
|
||||
svg = svg.replace(/<script[\s\S]*?<\/script\s*>/gi, "");
|
||||
svg = svg.replace(/<script\b[^>]*\/>/gi, "");
|
||||
|
||||
// Strip on* event handler attributes (onload, onclick, onerror, ...).
|
||||
svg = svg.replace(/\son\w+\s*=\s*"[^"]*"/gi, "");
|
||||
svg = svg.replace(/\son\w+\s*=\s*'[^']*'/gi, "");
|
||||
svg = svg.replace(/\son\w+\s*=\s*[^\s>]+/gi, "");
|
||||
|
||||
// Strip javascript: URIs in href/xlink:href/src attributes.
|
||||
svg = svg.replace(
|
||||
/((?:xlink:href|href|src)\s*=\s*)"javascript:[^"]*"/gi,
|
||||
'$1""',
|
||||
);
|
||||
svg = svg.replace(
|
||||
/((?:xlink:href|href|src)\s*=\s*)'javascript:[^']*'/gi,
|
||||
"$1''",
|
||||
);
|
||||
|
||||
// Strip <foreignObject> (can embed arbitrary HTML) and <iframe>.
|
||||
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject\s*>/gi, "");
|
||||
svg = svg.replace(/<iframe[\s\S]*?<\/iframe\s*>/gi, "");
|
||||
|
||||
return svg;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const STORAGE_KEY = "beenvoice:time-clock:last-client";
|
||||
|
||||
export function getLastTimeClockClientId(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function setLastTimeClockClientId(clientId: string): void {
|
||||
if (!clientId || typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEY, clientId);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Stored on entries clocked in before empty descriptions were allowed. */
|
||||
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||
|
||||
export function normalizeOptionalId(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed == null || trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
export function resolveEffectiveHourlyRate(
|
||||
enteredRate: number,
|
||||
client?: { defaultHourlyRate?: number | null } | null,
|
||||
): number {
|
||||
if (Number.isFinite(enteredRate) && enteredRate > 0) return enteredRate;
|
||||
const clientRate = client?.defaultHourlyRate ?? 0;
|
||||
if (Number.isFinite(clientRate) && clientRate > 0) return clientRate;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function startedAtFromMinutesAgo(minutes: number): Date {
|
||||
return new Date(Date.now() - minutes * 60 * 1000);
|
||||
}
|
||||
|
||||
export function resolveClockDescription(
|
||||
title: string,
|
||||
existingDescription?: string | null,
|
||||
): string {
|
||||
const trimmed = title.trim();
|
||||
if (trimmed) return trimmed;
|
||||
if (existingDescription?.trim()) return existingDescription.trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
export function formatRunningTimerLabel(description?: string | null): string {
|
||||
const trimmed = description?.trim() ?? "";
|
||||
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) return "Clocked in";
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveBillingDescription(description?: string | null): string {
|
||||
const trimmed = description?.trim() ?? "";
|
||||
if (!trimmed || trimmed === LEGACY_DEFAULT_CLOCK_DESCRIPTION) {
|
||||
return LEGACY_DEFAULT_CLOCK_DESCRIPTION;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export type ClockOutOutcome =
|
||||
| "linked_to_invoice"
|
||||
| "saved_no_invoice"
|
||||
| "saved_no_client"
|
||||
| "zero_hours";
|
||||
|
||||
export function computeTrackedHours(startedAt: Date, endedAt: Date): number {
|
||||
const seconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
return Math.max(0.25, Math.ceil(seconds / 900) * 0.25);
|
||||
}
|
||||
|
||||
export function formatElapsedSeconds(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
return [h, m, s].map((v) => String(v).padStart(2, "0")).join(":");
|
||||
}
|
||||
|
||||
export function describeClockOutOutcome(input: {
|
||||
outcome: ClockOutOutcome;
|
||||
hours: number;
|
||||
rate: number;
|
||||
invoice?: { invoicePrefix: string; invoiceNumber: string } | null;
|
||||
}): string {
|
||||
const amount = input.hours * input.rate;
|
||||
|
||||
switch (input.outcome) {
|
||||
case "linked_to_invoice":
|
||||
if (input.invoice) {
|
||||
const label = `${input.invoice.invoicePrefix}${input.invoice.invoiceNumber}`;
|
||||
return `Added ${input.hours}h @ $${input.rate}/hr ($${amount.toFixed(2)}) to invoice ${label}`;
|
||||
}
|
||||
return `Added ${input.hours}h to invoice`;
|
||||
case "saved_no_invoice":
|
||||
return `Saved ${input.hours}h — could not create or find a draft invoice for this client.`;
|
||||
case "saved_no_client":
|
||||
return `Saved ${input.hours}h — assign a client and invoice to bill this time.`;
|
||||
case "zero_hours":
|
||||
return "Timer stopped (less than minimum billable increment).";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export function invoiceLabel(inv: {
|
||||
invoicePrefix: string | null;
|
||||
invoiceNumber: string;
|
||||
}) {
|
||||
return `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`;
|
||||
}
|
||||
|
||||
export function entryHref(entry: {
|
||||
invoiceId: string | null;
|
||||
clientId: string | null;
|
||||
invoice?: { id: string } | null;
|
||||
client?: { id: string } | null;
|
||||
}): string | null {
|
||||
const invoiceId = entry.invoiceId ?? entry.invoice?.id;
|
||||
if (invoiceId) return `/dashboard/invoices/${invoiceId}`;
|
||||
const clientId = entry.clientId ?? entry.client?.id;
|
||||
if (clientId) return `/dashboard/clients/${clientId}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type TimeEntryListItem = {
|
||||
id: string;
|
||||
description: string | null;
|
||||
hours: number | null;
|
||||
rate: number | null;
|
||||
startedAt: Date;
|
||||
endedAt: Date | null;
|
||||
clientId: string | null;
|
||||
invoiceId: string | null;
|
||||
client?: { id: string; name: string } | null;
|
||||
invoice?: {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
invoicePrefix: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function groupEntriesByDate<T extends { startedAt: Date }>(
|
||||
entries: T[],
|
||||
): { dateKey: string; label: string; entries: T[] }[] {
|
||||
const groups = new Map<string, T[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const d = new Date(entry.startedAt);
|
||||
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const existing = groups.get(dateKey);
|
||||
if (existing) {
|
||||
existing.push(entry);
|
||||
} else {
|
||||
groups.set(dateKey, [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([dateKey, groupEntries]) => {
|
||||
const sample = new Date(groupEntries[0]!.startedAt);
|
||||
const label = sample.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
return { dateKey, label, entries: groupEntries };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user