feat: add administration page and account role management
- Implemented `AdministrationContent` component for managing account roles. - Created `AdministrationPage` to serve as the main entry point for administration tasks. - Added PDF preview functionality with `PdfPreviewFrame` component for invoice generation. - Introduced `InputColor` component for advanced color selection with various formats. - Established color conversion utilities in `color-converter.ts` for handling color formats. - Defined appearance-related schemas and types in `appearance.ts` for consistent theme management.
This commit is contained in:
+109
-104
@@ -3,123 +3,128 @@ import { invoices, clients } from "~/server/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
|
||||
export const dashboardRouter = createTRPCRouter({
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
const now = new Date();
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
const now = new Date();
|
||||
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
|
||||
// 1. Fetch all invoices for the user to calculate stats
|
||||
// Note: For very large datasets, we should use separate count/sum queries,
|
||||
// but for typical usage, fetching fields is fine and allows flexible JS calculation
|
||||
// where SQL complexity might be high (e.g. dynamic status).
|
||||
// However, let's try to be efficient with SQL where possible.
|
||||
// 1. Fetch all invoices for the user to calculate stats
|
||||
// Note: For very large datasets, we should use separate count/sum queries,
|
||||
// but for typical usage, fetching fields is fine and allows flexible JS calculation
|
||||
// where SQL complexity might be high (e.g. dynamic status).
|
||||
// However, let's try to be efficient with SQL where possible.
|
||||
|
||||
const userInvoices = await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
totalAmount: true,
|
||||
status: true,
|
||||
dueDate: true,
|
||||
issueDate: true,
|
||||
},
|
||||
});
|
||||
const userInvoices = await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
totalAmount: true,
|
||||
status: true,
|
||||
dueDate: true,
|
||||
issueDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userClientsCount = await ctx.db.$count(
|
||||
clients,
|
||||
eq(clients.createdById, userId),
|
||||
);
|
||||
const userClientsCount = await ctx.db.$count(
|
||||
clients,
|
||||
eq(clients.createdById, userId),
|
||||
);
|
||||
|
||||
// Helper to check status
|
||||
const getStatus = (inv: typeof userInvoices[0]) => {
|
||||
if (inv.status === "paid") return "paid";
|
||||
if (inv.status === "draft") return "draft";
|
||||
if (new Date(inv.dueDate) < now && inv.status !== "paid") return "overdue";
|
||||
return "sent";
|
||||
};
|
||||
// Helper to check status
|
||||
const getStatus = (inv: (typeof userInvoices)[0]) => {
|
||||
if (inv.status === "paid") return "paid";
|
||||
if (inv.status === "draft") return "draft";
|
||||
if (new Date(inv.dueDate) < now && inv.status !== "paid")
|
||||
return "overdue";
|
||||
return "sent";
|
||||
};
|
||||
|
||||
// Calculate Stats
|
||||
let totalRevenue = 0;
|
||||
let pendingAmount = 0;
|
||||
let overdueCount = 0;
|
||||
// Calculate Stats
|
||||
let totalRevenue = 0;
|
||||
let pendingAmount = 0;
|
||||
let overdueCount = 0;
|
||||
|
||||
let currentMonthRevenue = 0;
|
||||
let lastMonthRevenue = 0;
|
||||
let currentMonthRevenue = 0;
|
||||
let lastMonthRevenue = 0;
|
||||
|
||||
for (const inv of userInvoices) {
|
||||
const status = getStatus(inv);
|
||||
const amount = inv.totalAmount;
|
||||
const issueDate = new Date(inv.issueDate);
|
||||
for (const inv of userInvoices) {
|
||||
const status = getStatus(inv);
|
||||
const amount = inv.totalAmount;
|
||||
const issueDate = new Date(inv.issueDate);
|
||||
|
||||
if (status === "paid") {
|
||||
totalRevenue += amount;
|
||||
if (status === "paid") {
|
||||
totalRevenue += amount;
|
||||
|
||||
if (issueDate >= currentMonthStart) {
|
||||
currentMonthRevenue += amount;
|
||||
} else if (issueDate >= lastMonthStart && issueDate < currentMonthStart) {
|
||||
lastMonthRevenue += amount;
|
||||
}
|
||||
} else if (status === "sent" || status === "overdue") {
|
||||
pendingAmount += amount;
|
||||
}
|
||||
|
||||
if (status === "overdue") {
|
||||
overdueCount++;
|
||||
}
|
||||
if (issueDate >= currentMonthStart) {
|
||||
currentMonthRevenue += amount;
|
||||
} else if (
|
||||
issueDate >= lastMonthStart &&
|
||||
issueDate < currentMonthStart
|
||||
) {
|
||||
lastMonthRevenue += amount;
|
||||
}
|
||||
} else if (status === "sent" || status === "overdue") {
|
||||
pendingAmount += amount;
|
||||
}
|
||||
|
||||
// Revenue Trend (Last 6 months)
|
||||
const revenueByMonth: Record<string, number> = {};
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
revenueByMonth[key] = 0;
|
||||
if (status === "overdue") {
|
||||
overdueCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Revenue Trend (Last 6 months)
|
||||
const revenueByMonth: Record<string, number> = {};
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
revenueByMonth[key] = 0;
|
||||
}
|
||||
|
||||
for (const inv of userInvoices) {
|
||||
if (getStatus(inv) === "paid") {
|
||||
const d = new Date(inv.issueDate);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
if (revenueByMonth[key] !== undefined) {
|
||||
revenueByMonth[key] += inv.totalAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const inv of userInvoices) {
|
||||
if (getStatus(inv) === "paid") {
|
||||
const d = new Date(inv.issueDate);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
if (revenueByMonth[key] !== undefined) {
|
||||
revenueByMonth[key] += inv.totalAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
const revenueChartData = Object.entries(revenueByMonth)
|
||||
.map(([month, revenue]) => ({
|
||||
month,
|
||||
revenue,
|
||||
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
const revenueChartData = Object.entries(revenueByMonth)
|
||||
.map(([month, revenue]) => ({
|
||||
month,
|
||||
revenue,
|
||||
monthLabel: new Date(month + "-01").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
// Recent Activity
|
||||
const recentInvoices = await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, userId),
|
||||
orderBy: [desc(invoices.issueDate)],
|
||||
limit: 5,
|
||||
with: {
|
||||
client: {
|
||||
columns: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Recent Activity
|
||||
const recentInvoices = await ctx.db.query.invoices.findMany({
|
||||
where: eq(invoices.createdById, userId),
|
||||
orderBy: [desc(invoices.issueDate)],
|
||||
limit: 5,
|
||||
with: {
|
||||
client: {
|
||||
columns: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
pendingAmount,
|
||||
overdueCount,
|
||||
totalClients: userClientsCount,
|
||||
revenueChange: lastMonthRevenue > 0
|
||||
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
|
||||
: 0,
|
||||
revenueChartData,
|
||||
recentInvoices,
|
||||
};
|
||||
}),
|
||||
return {
|
||||
totalRevenue,
|
||||
pendingAmount,
|
||||
overdueCount,
|
||||
totalClients: userClientsCount,
|
||||
revenueChange:
|
||||
lastMonthRevenue > 0
|
||||
? ((currentMonthRevenue - lastMonthRevenue) / lastMonthRevenue) * 100
|
||||
: 0,
|
||||
revenueChartData,
|
||||
recentInvoices,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -47,7 +47,10 @@ export const expensesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!expense) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Expense not found",
|
||||
});
|
||||
}
|
||||
|
||||
return expense;
|
||||
@@ -72,7 +75,11 @@ export const expensesRouter = createTRPCRouter({
|
||||
eq(clients.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||
if (!client)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Client not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (clean.businessId) {
|
||||
@@ -82,7 +89,11 @@ export const expensesRouter = createTRPCRouter({
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!business) throw new TRPCError({ code: "FORBIDDEN", message: "Business not found" });
|
||||
if (!business)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Business not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (clean.invoiceId) {
|
||||
@@ -92,7 +103,11 @@ export const expensesRouter = createTRPCRouter({
|
||||
eq(invoices.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" });
|
||||
if (!invoice)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
|
||||
const [expense] = await ctx.db
|
||||
@@ -116,7 +131,10 @@ export const expensesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Expense not found",
|
||||
});
|
||||
}
|
||||
|
||||
const clean = {
|
||||
@@ -145,7 +163,10 @@ export const expensesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Expense not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
|
||||
|
||||
@@ -72,7 +72,10 @@ export const invoiceTemplatesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" });
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Template not found",
|
||||
});
|
||||
}
|
||||
|
||||
// If setting as default, unset others of same type
|
||||
@@ -108,7 +111,10 @@ export const invoiceTemplatesRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" });
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Template not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
|
||||
@@ -17,12 +17,20 @@ import {
|
||||
platformSettings,
|
||||
} from "~/server/db/schema";
|
||||
import {
|
||||
colorModeSchema,
|
||||
colorThemeSchema,
|
||||
defaultBodyFontPreference,
|
||||
defaultFontPreference,
|
||||
defaultHeadingFontPreference,
|
||||
defaultInterfaceTheme,
|
||||
defaultRadiusPreference,
|
||||
defaultSidebarStyle,
|
||||
fallbackAppearance,
|
||||
fontPreferenceSchema,
|
||||
hslChannelsSchema,
|
||||
interfaceThemeSchema,
|
||||
pdfTemplateSchema,
|
||||
radiusPreferenceSchema,
|
||||
sidebarStyleSchema,
|
||||
type ColorMode,
|
||||
type ColorTheme,
|
||||
type FontPreference,
|
||||
@@ -219,12 +227,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
return {
|
||||
colorTheme: (settings?.colorTheme as ColorTheme) ?? "slate",
|
||||
colorTheme:
|
||||
(settings?.colorTheme as ColorTheme) ?? fallbackAppearance.colorTheme,
|
||||
customColor: settings?.customColor ?? undefined,
|
||||
theme: (settings?.theme as ColorMode) ?? "system",
|
||||
theme: (settings?.theme as ColorMode) ?? fallbackAppearance.colorMode,
|
||||
interfaceTheme:
|
||||
(settings?.interfaceTheme as InterfaceTheme) ?? defaultInterfaceTheme,
|
||||
fontPreference: defaultFontPreference,
|
||||
bodyFontPreference:
|
||||
(settings?.bodyFontPreference as FontPreference) ??
|
||||
defaultBodyFontPreference,
|
||||
@@ -236,18 +244,21 @@ export const settingsRouter = createTRPCRouter({
|
||||
defaultRadiusPreference,
|
||||
sidebarStyle:
|
||||
(settings?.sidebarStyle as SidebarStyle) ?? defaultSidebarStyle,
|
||||
brandName: settings?.brandName ?? "beenvoice",
|
||||
brandTagline:
|
||||
settings?.brandTagline ??
|
||||
"Simple and efficient invoicing for freelancers and small businesses",
|
||||
brandLogoText: settings?.brandLogoText ?? "beenvoice",
|
||||
brandIcon: settings?.brandIcon ?? "$",
|
||||
brandName: settings?.brandName ?? fallbackAppearance.brandName,
|
||||
brandTagline: settings?.brandTagline ?? fallbackAppearance.brandTagline,
|
||||
brandLogoText:
|
||||
settings?.brandLogoText ?? fallbackAppearance.brandLogoText,
|
||||
brandIcon: settings?.brandIcon ?? fallbackAppearance.brandIcon,
|
||||
pdfTemplate:
|
||||
(settings?.pdfTemplate as "classic" | "minimal") ?? "classic",
|
||||
pdfAccentColor: settings?.pdfAccentColor ?? "#111827",
|
||||
pdfFooterText: settings?.pdfFooterText ?? "Professional Invoicing",
|
||||
pdfShowLogo: settings?.pdfShowLogo ?? true,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers ?? true,
|
||||
(settings?.pdfTemplate as "classic" | "minimal") ??
|
||||
fallbackAppearance.pdfTemplate,
|
||||
pdfAccentColor:
|
||||
settings?.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
|
||||
pdfFooterText:
|
||||
settings?.pdfFooterText ?? fallbackAppearance.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
|
||||
pdfShowPageNumbers:
|
||||
settings?.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -255,30 +266,19 @@ export const settingsRouter = createTRPCRouter({
|
||||
updateTheme: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
colorTheme: z
|
||||
.enum(["slate", "blue", "green", "rose", "orange", "custom"])
|
||||
.optional(),
|
||||
customColor: z.string().optional(),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
interfaceTheme: z
|
||||
.enum(["beenvoice", "shadcn", "minimal", "editorial"])
|
||||
.optional(),
|
||||
fontPreference: z
|
||||
.enum(["brand", "platform", "inter", "serif"])
|
||||
.optional(),
|
||||
bodyFontPreference: z
|
||||
.enum(["brand", "platform", "inter", "serif"])
|
||||
.optional(),
|
||||
headingFontPreference: z
|
||||
.enum(["brand", "platform", "inter", "serif"])
|
||||
.optional(),
|
||||
radiusPreference: z.enum(["none", "sm", "md", "lg", "xl"]).optional(),
|
||||
sidebarStyle: z.enum(["floating", "docked"]).optional(),
|
||||
colorTheme: colorThemeSchema.optional(),
|
||||
customColor: hslChannelsSchema.optional(),
|
||||
theme: colorModeSchema.optional(),
|
||||
interfaceTheme: interfaceThemeSchema.optional(),
|
||||
bodyFontPreference: fontPreferenceSchema.optional(),
|
||||
headingFontPreference: fontPreferenceSchema.optional(),
|
||||
radiusPreference: radiusPreferenceSchema.optional(),
|
||||
sidebarStyle: sidebarStyleSchema.optional(),
|
||||
brandName: z.string().min(1).max(100).optional(),
|
||||
brandTagline: z.string().min(1).max(255).optional(),
|
||||
brandLogoText: z.string().min(1).max(100).optional(),
|
||||
brandIcon: z.string().min(1).max(20).optional(),
|
||||
pdfTemplate: z.enum(["classic", "minimal"]).optional(),
|
||||
pdfTemplate: pdfTemplateSchema.optional(),
|
||||
pdfAccentColor: z.string().min(4).max(50).optional(),
|
||||
pdfFooterText: z.string().min(1).max(120).optional(),
|
||||
pdfShowLogo: z.boolean().optional(),
|
||||
@@ -291,15 +291,14 @@ export const settingsRouter = createTRPCRouter({
|
||||
.insert(platformSettings)
|
||||
.values({
|
||||
id: "global",
|
||||
brandName: input.brandName ?? "beenvoice",
|
||||
brandTagline:
|
||||
input.brandTagline ??
|
||||
"Simple and efficient invoicing for freelancers and small businesses",
|
||||
brandLogoText: input.brandLogoText ?? "beenvoice",
|
||||
brandIcon: input.brandIcon ?? "$",
|
||||
colorTheme: input.colorTheme ?? "slate",
|
||||
brandName: input.brandName ?? fallbackAppearance.brandName,
|
||||
brandTagline: input.brandTagline ?? fallbackAppearance.brandTagline,
|
||||
brandLogoText:
|
||||
input.brandLogoText ?? fallbackAppearance.brandLogoText,
|
||||
brandIcon: input.brandIcon ?? fallbackAppearance.brandIcon,
|
||||
colorTheme: input.colorTheme ?? fallbackAppearance.colorTheme,
|
||||
customColor: input.customColor,
|
||||
theme: input.theme ?? "system",
|
||||
theme: input.theme ?? fallbackAppearance.colorMode,
|
||||
interfaceTheme: input.interfaceTheme ?? defaultInterfaceTheme,
|
||||
bodyFontPreference:
|
||||
input.bodyFontPreference ?? defaultBodyFontPreference,
|
||||
@@ -307,11 +306,14 @@ export const settingsRouter = createTRPCRouter({
|
||||
input.headingFontPreference ?? defaultHeadingFontPreference,
|
||||
radiusPreference: input.radiusPreference ?? defaultRadiusPreference,
|
||||
sidebarStyle: input.sidebarStyle ?? defaultSidebarStyle,
|
||||
pdfTemplate: input.pdfTemplate ?? "classic",
|
||||
pdfAccentColor: input.pdfAccentColor ?? "#111827",
|
||||
pdfFooterText: input.pdfFooterText ?? "Professional Invoicing",
|
||||
pdfShowLogo: input.pdfShowLogo ?? true,
|
||||
pdfShowPageNumbers: input.pdfShowPageNumbers ?? true,
|
||||
pdfTemplate: input.pdfTemplate ?? fallbackAppearance.pdfTemplate,
|
||||
pdfAccentColor:
|
||||
input.pdfAccentColor ?? fallbackAppearance.pdfAccentColor,
|
||||
pdfFooterText:
|
||||
input.pdfFooterText ?? fallbackAppearance.pdfFooterText,
|
||||
pdfShowLogo: input.pdfShowLogo ?? fallbackAppearance.pdfShowLogo,
|
||||
pdfShowPageNumbers:
|
||||
input.pdfShowPageNumbers ?? fallbackAppearance.pdfShowPageNumbers,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: platformSettings.id,
|
||||
|
||||
+56
-56
@@ -1,65 +1,65 @@
|
||||
import { env } from "~/env";
|
||||
|
||||
type UmamiPayload = {
|
||||
payload: {
|
||||
hostname: string;
|
||||
language: string;
|
||||
referrer: string;
|
||||
screen: string;
|
||||
title: string;
|
||||
url: string;
|
||||
website: string;
|
||||
name: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
type: "event";
|
||||
payload: {
|
||||
hostname: string;
|
||||
language: string;
|
||||
referrer: string;
|
||||
screen: string;
|
||||
title: string;
|
||||
url: string;
|
||||
website: string;
|
||||
name: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
type: "event";
|
||||
};
|
||||
|
||||
export async function trackServerEvent(
|
||||
eventName: string,
|
||||
eventData?: Record<string, unknown>,
|
||||
eventName: string,
|
||||
eventData?: Record<string, unknown>,
|
||||
) {
|
||||
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
|
||||
console.warn("Umami not configured, skipping server-side event tracking");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract API endpoint from script URL (e.g., https://analytics.umami.is/script.js -> https://analytics.umami.is/api/send)
|
||||
const scriptUrl = new URL(env.NEXT_PUBLIC_UMAMI_SCRIPT_URL);
|
||||
const apiUrl = `${scriptUrl.origin}/api/send`;
|
||||
|
||||
const payload: UmamiPayload = {
|
||||
payload: {
|
||||
hostname: env.NEXT_PUBLIC_APP_URL
|
||||
? new URL(env.NEXT_PUBLIC_APP_URL).hostname
|
||||
: "localhost",
|
||||
language: "en-US",
|
||||
referrer: "",
|
||||
screen: "",
|
||||
title: "Server Event",
|
||||
url: "/",
|
||||
website: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
name: eventName,
|
||||
data: eventData,
|
||||
},
|
||||
type: "event",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to send Umami event:", await response.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending Umami event:", error);
|
||||
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
|
||||
console.warn("Umami not configured, skipping server-side event tracking");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract API endpoint from script URL (e.g., https://analytics.umami.is/script.js -> https://analytics.umami.is/api/send)
|
||||
const scriptUrl = new URL(env.NEXT_PUBLIC_UMAMI_SCRIPT_URL);
|
||||
const apiUrl = `${scriptUrl.origin}/api/send`;
|
||||
|
||||
const payload: UmamiPayload = {
|
||||
payload: {
|
||||
hostname: env.NEXT_PUBLIC_APP_URL
|
||||
? new URL(env.NEXT_PUBLIC_APP_URL).hostname
|
||||
: "localhost",
|
||||
language: "en-US",
|
||||
referrer: "",
|
||||
screen: "",
|
||||
title: "Server Event",
|
||||
url: "/",
|
||||
website: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
name: eventName,
|
||||
data: eventData,
|
||||
},
|
||||
type: "event",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to send Umami event:", await response.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending Umami event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user