From b0caa0709a69e2cd8324822ed930a98bf6eadb32 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:20:19 +0100 Subject: [PATCH 01/18] feat: add app site type support to database schema and API --- client/src/api/admin/endpoints/sites.ts | 4 ++++ server/src/api/sites/addSite.ts | 24 +++++++++++++------ server/src/api/sites/getSite.ts | 1 + server/src/api/sites/updateSiteConfig.ts | 30 ++++++++++++++++++------ server/src/db/clickhouse/clickhouse.ts | 8 +++++++ server/src/db/postgres/schema.ts | 1 + 6 files changed, 54 insertions(+), 14 deletions(-) diff --git a/client/src/api/admin/endpoints/sites.ts b/client/src/api/admin/endpoints/sites.ts index dd0d62845..abe067e76 100644 --- a/client/src/api/admin/endpoints/sites.ts +++ b/client/src/api/admin/endpoints/sites.ts @@ -5,6 +5,7 @@ export type SiteResponse = { siteId: number; name: string; domain: string; + type?: "web" | "app"; createdAt: string; updatedAt: string; createdBy: string; @@ -45,6 +46,7 @@ export type GetSitesFromOrgResponse = { siteId: number; name: string; domain: string; + type?: "web" | "app"; createdAt: string; updatedAt: string; createdBy: string; @@ -78,6 +80,7 @@ export function addSite( isPublic?: boolean; saltUserIds?: boolean; blockBots?: boolean; + type?: "web" | "app"; } ) { return authedFetch<{ siteId: number }>(`/organizations/${organizationId}/sites`, undefined, { @@ -88,6 +91,7 @@ export function addSite( public: settings?.isPublic || false, saltUserIds: settings?.saltUserIds || false, blockBots: settings?.blockBots === undefined ? true : settings?.blockBots, + type: settings?.type || "web", }, headers: { "Content-Type": "application/json", diff --git a/server/src/api/sites/addSite.ts b/server/src/api/sites/addSite.ts index d126bfbe0..635d7ba20 100644 --- a/server/src/api/sites/addSite.ts +++ b/server/src/api/sites/addSite.ts @@ -31,6 +31,7 @@ export async function addSite( trackCopy?: boolean; trackFormInteractions?: boolean; tags?: string[]; + type?: "web" | "app"; }; }>, reply: FastifyReply @@ -56,17 +57,25 @@ export async function addSite( trackCopy, trackFormInteractions, tags, + type: siteType = "web", } = request.body; - // Strip protocol and trailing slash before validation const cleanedDomain = domain.replace(/^https?:\/\//, "").replace(/\/+$/, ""); - // Validate domain format using regex - const domainRegex = /^(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+\p{L}{2,}$/u; - if (!domainRegex.test(cleanedDomain)) { - return reply.status(400).send({ - error: "Invalid domain format. Must be a valid domain like example.com or sub.example.com", - }); + if (siteType === "web") { + const domainRegex = /^(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+\p{L}{2,}$/u; + if (!domainRegex.test(cleanedDomain)) { + return reply.status(400).send({ + error: "Invalid domain format. Must be a valid domain like example.com or sub.example.com", + }); + } + } else { + const packageNameRegex = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/; + if (!packageNameRegex.test(cleanedDomain)) { + return reply.status(400).send({ + error: "Invalid package name format. Must be a valid identifier like com.example.app", + }); + } } try { @@ -111,6 +120,7 @@ export async function addSite( .insert(sites) .values({ id, + type: siteType, domain: cleanedDomain, name, createdBy: userId, diff --git a/server/src/api/sites/getSite.ts b/server/src/api/sites/getSite.ts index f46975ce0..220bee7b3 100644 --- a/server/src/api/sites/getSite.ts +++ b/server/src/api/sites/getSite.ts @@ -31,6 +31,7 @@ export async function getSite(request: FastifyRequest, reply: Fas siteId: site.siteId, name: site.name, domain: site.domain, + type: site.type, createdAt: site.createdAt, updatedAt: site.updatedAt, createdBy: site.createdBy, diff --git a/server/src/api/sites/updateSiteConfig.ts b/server/src/api/sites/updateSiteConfig.ts index 114e84a08..7b3fcd09d 100644 --- a/server/src/api/sites/updateSiteConfig.ts +++ b/server/src/api/sites/updateSiteConfig.ts @@ -13,13 +13,7 @@ const updateSiteConfigSchema = z.object({ public: z.boolean().optional(), saltUserIds: z.boolean().optional(), blockBots: z.boolean().optional(), - domain: z - .string() - .regex( - /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, - "Invalid domain format. Must be a valid domain like example.com or sub.example.com" - ) - .optional(), + domain: z.string().min(1).max(253).optional(), excludedIPs: z.array(z.string().trim().min(1)).max(100).optional(), excludedCountries: z .array( @@ -86,6 +80,28 @@ export async function updateSiteConfig( return reply.status(404).send({ error: "Site not found" }); } + // Validate domain based on site type + if (updateData.domain) { + const siteType = (site as any).type || "web"; + if (siteType === "web") { + const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; + if (!domainRegex.test(updateData.domain)) { + return reply.status(400).send({ + success: false, + error: "Invalid domain format. Must be a valid domain like example.com or sub.example.com", + }); + } + } else { + const packageNameRegex = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/; + if (!packageNameRegex.test(updateData.domain)) { + return reply.status(400).send({ + success: false, + error: "Invalid package name format. Must be like com.example.app", + }); + } + } + } + // Additional validation for excluded IPs if provided if (updateData.excludedIPs) { const validationErrors: string[] = []; diff --git a/server/src/db/clickhouse/clickhouse.ts b/server/src/db/clickhouse/clickhouse.ts index 30d25ffdd..5d010393d 100644 --- a/server/src/db/clickhouse/clickhouse.ts +++ b/server/src/db/clickhouse/clickhouse.ts @@ -63,6 +63,14 @@ export const initializeClickhouse = async () => { `, }); + await clickhouse.exec({ + query: ` + ALTER TABLE events + ADD COLUMN IF NOT EXISTS app_version LowCardinality(String) DEFAULT '', + ADD COLUMN IF NOT EXISTS device_model LowCardinality(String) DEFAULT '' + `, + }); + if (IS_CLOUD) { await clickhouse.exec({ query: ` diff --git a/server/src/db/postgres/schema.ts b/server/src/db/postgres/schema.ts index f5b447191..98b81ccd6 100644 --- a/server/src/db/postgres/schema.ts +++ b/server/src/db/postgres/schema.ts @@ -63,6 +63,7 @@ export const sites = pgTable("sites", { siteId: serial("site_id").primaryKey().notNull(), name: text("name").notNull(), domain: text("domain").notNull(), + type: text("type").default("web").notNull(), // 'web', 'app' createdAt: timestamp("created_at", { mode: "string" }).defaultNow(), updatedAt: timestamp("updated_at", { mode: "string" }).defaultNow(), createdBy: text("created_by").references(() => user.id, { onDelete: "set null" }), From 9eb5eb0af6c710ba03fac34ffa3bc31d2d9d8518 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:20:25 +0100 Subject: [PATCH 02/18] feat: add app_version and device_model tracking for SDK clients --- client/src/api/analytics/endpoints/events.ts | 2 ++ client/src/api/analytics/endpoints/sessions.ts | 4 ++++ client/src/api/analytics/endpoints/users.ts | 4 ++++ server/src/api/analytics/utils/query-validation.ts | 2 ++ server/src/services/tracker/pageviewQueue.ts | 13 ++++++++----- server/src/services/tracker/trackEvent.ts | 2 ++ 6 files changed, 22 insertions(+), 5 deletions(-) diff --git a/client/src/api/analytics/endpoints/events.ts b/client/src/api/analytics/endpoints/events.ts index 7192f524f..88cd65979 100644 --- a/client/src/api/analytics/endpoints/events.ts +++ b/client/src/api/analytics/endpoints/events.ts @@ -35,6 +35,8 @@ export type Event = { device_type: string; type: string; traits?: Record | null; + device_model: string; + app_version: string; }; // Response types for cursor-based API diff --git a/client/src/api/analytics/endpoints/sessions.ts b/client/src/api/analytics/endpoints/sessions.ts index 86df22151..f9d84a033 100644 --- a/client/src/api/analytics/endpoints/sessions.ts +++ b/client/src/api/analytics/endpoints/sessions.ts @@ -18,6 +18,8 @@ export type GetSessionsResponse = { operating_system_version: string; screen_width: number; screen_height: number; + device_model: string; + app_version: string; referrer: string; channel: string; hostname: string; @@ -56,6 +58,8 @@ export interface SessionDetails { city: string; language: string; device_type: string; + device_model: string; + app_version: string; browser: string; browser_version: string; operating_system: string; diff --git a/client/src/api/analytics/endpoints/users.ts b/client/src/api/analytics/endpoints/users.ts index d08911922..ff7d49b20 100644 --- a/client/src/api/analytics/endpoints/users.ts +++ b/client/src/api/analytics/endpoints/users.ts @@ -14,6 +14,8 @@ export type UsersResponse = { browser: string; operating_system: string; device_type: string; + device_model: string; + app_version: string; referrer: string; channel: string; pageviews: number; @@ -40,6 +42,8 @@ export type UserInfo = { city: string; language: string; device_type: string; + device_model: string; + app_version: string; browser: string; browser_version: string; operating_system: string; diff --git a/server/src/api/analytics/utils/query-validation.ts b/server/src/api/analytics/utils/query-validation.ts index 605a388cf..04ee9459f 100644 --- a/server/src/api/analytics/utils/query-validation.ts +++ b/server/src/api/analytics/utils/query-validation.ts @@ -201,6 +201,8 @@ export const filterParamSchema = z.enum([ "region", "city", "device_type", + "device_model", + "app_version", "referrer", "hostname", "pathname", diff --git a/server/src/services/tracker/pageviewQueue.ts b/server/src/services/tracker/pageviewQueue.ts index 458144a1b..ba986de17 100644 --- a/server/src/services/tracker/pageviewQueue.ts +++ b/server/src/services/tracker/pageviewQueue.ts @@ -2,7 +2,7 @@ import { DateTime } from "luxon"; import { clickhouse } from "../../db/clickhouse/clickhouse.js"; import { getLocation } from "../../db/geolocation/geolocation.js"; import { createServiceLogger } from "../../lib/logger/logger.js"; -import { getDeviceType } from "../../utils.js"; +import { getDeviceType, parseSDKUserAgent } from "../../utils.js"; import { getChannel } from "./getChannel.js"; import { clearSelfReferrer, getAllUrlParams, TotalTrackingPayload } from "./utils.js"; @@ -59,6 +59,7 @@ class PageviewQueue { const longitude = dataForIp?.longitude || 0; const city = dataForIp?.city || ""; const timezone = dataForIp?.timeZone || ""; + const sdkUA = parseSDKUserAgent(pv.ua.ua || ""); // Check if referrer is from the same domain and clear it if so let referrer = clearSelfReferrer(pv.referrer || "", pv.hostname || ""); @@ -79,10 +80,10 @@ class PageviewQueue { page_title: pv.page_title || "", referrer: referrer, channel: getChannel(referrer, pv.querystring, pv.hostname), - browser: pv.ua.browser.name || "", - browser_version: pv.ua.browser.major || "", - operating_system: pv.ua.os.name || "", - operating_system_version: pv.ua.os.version || "", + browser: sdkUA ? sdkUA.browser : (pv.ua.browser.name || ""), + browser_version: sdkUA ? sdkUA.browserVersion : (pv.ua.browser.major || ""), + operating_system: sdkUA ? sdkUA.os : (pv.ua.os.name || ""), + operating_system_version: sdkUA ? sdkUA.osVersion : (pv.ua.os.version || ""), language: pv.language || "", screen_width: pv.screenWidth || 0, screen_height: pv.screenHeight || 0, @@ -121,6 +122,8 @@ class PageviewQueue { is_proxy: dataForIp?.isProxy ?? null, is_tor: dataForIp?.isTor ?? null, is_satellite: dataForIp?.isSatellite ?? null, + app_version: pv.app_version || "", + device_model: pv.device_model || "", }; }); diff --git a/server/src/services/tracker/trackEvent.ts b/server/src/services/tracker/trackEvent.ts index 158331828..e09a6fe1b 100644 --- a/server/src/services/tracker/trackEvent.ts +++ b/server/src/services/tracker/trackEvent.ts @@ -27,6 +27,8 @@ const baseEventFields = { ip_address: z.string().ip().optional(), user_agent: z.string().max(512).optional(), _bs: z.number().int().min(0).max(10).optional(), + app_version: z.string().max(50).optional(), + device_model: z.string().max(200).optional(), }; // Default event_name and properties used by pageview and performance From 5c81b2009d79a2e845e9c9aedd47d031a793e53f Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:20:31 +0100 Subject: [PATCH 03/18] feat: RFC 7231 User-Agent parsing for SDK clients --- server/src/services/tracker/utils.ts | 2 ++ server/src/utils.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/server/src/services/tracker/utils.ts b/server/src/services/tracker/utils.ts index 5f696d985..8f972d55b 100644 --- a/server/src/services/tracker/utils.ts +++ b/server/src/services/tracker/utils.ts @@ -25,6 +25,8 @@ export type TotalTrackingPayload = TrackingPayload & { fcp?: number; ttfb?: number; tag?: string; + app_version?: string; + device_model?: string; }; // Infer type from Zod schema diff --git a/server/src/utils.ts b/server/src/utils.ts index 89364d692..352bb24ee 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -123,6 +123,44 @@ export function getDeviceType(screenWidth: number, screenHeight: number, ua: UAP return "Mobile"; } +// RFC 7231 §5.5.3 User-Agent parser for SDK clients. +// 3-part: AppName/Version (packageName; Platform OS; deviceModel) SDKName/Version +// 2-part: AppName/Version (Platform OS; deviceModel) SDKName/Version +export function parseSDKUserAgent(userAgent: string): { + browser: string; + browserVersion: string; + os: string; + osVersion: string; +} | null { + // 3-part format (with packageName) + const match3 = userAgent.match( + /^(.+?)\/(\S+)\s+\([^;]+;\s*(\w+)\s+([^;]+);\s*[^)]+\)\s+\S+\/\S+$/ + ); + if (match3) { + return { + browser: match3[1], + browserVersion: match3[2], + os: match3[3], + osVersion: match3[4].trim(), + }; + } + + // 2-part format (without packageName) + const match2 = userAgent.match( + /^(.+?)\/(\S+)\s+\((\w+)\s+([^;]+);\s*[^)]+\)\s+\S+\/\S+$/ + ); + if (match2) { + return { + browser: match2[1], + browserVersion: match2[2], + os: match2[3], + osVersion: match2[4].trim(), + }; + } + + return null; +} + // Extract site ID from path export const extractSiteId = (path: string) => { // Remove query parameters if present From 5b8249b8e443fbb2d7788179d231933424b94be3 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:20:37 +0100 Subject: [PATCH 04/18] feat: platform-aware filter system (web vs app) --- .../components/SubHeader/Export/exportCsv.ts | 2 + .../SubHeader/Filters/FilterComponent.tsx | 2 + .../components/SubHeader/Filters/Filters.tsx | 2 + .../components/SubHeader/Filters/const.tsx | 10 + .../components/SubHeader/Filters/utils.ts | 4 + client/src/lib/filterGroups.ts | 177 ++++++++++++------ shared/src/filters.ts | 2 + 7 files changed, 139 insertions(+), 60 deletions(-) diff --git a/client/src/app/[site]/components/SubHeader/Export/exportCsv.ts b/client/src/app/[site]/components/SubHeader/Export/exportCsv.ts index 3c9f08e7f..4b4601221 100644 --- a/client/src/app/[site]/components/SubHeader/Export/exportCsv.ts +++ b/client/src/app/[site]/components/SubHeader/Export/exportCsv.ts @@ -34,6 +34,8 @@ const PAGE_METRICS: { param: FilterParameter; filename: string }[] = [ const DEVICE_METRICS: { param: FilterParameter; filename: string }[] = [ { param: "browser", filename: "browsers.csv" }, { param: "device_type", filename: "devices.csv" }, + { param: "device_model", filename: "device-models.csv" }, + { param: "app_version", filename: "app-versions.csv" }, { param: "operating_system", filename: "operating-systems.csv" }, { param: "dimensions", filename: "screen-dimensions.csv" }, ]; diff --git a/client/src/app/[site]/components/SubHeader/Filters/FilterComponent.tsx b/client/src/app/[site]/components/SubHeader/Filters/FilterComponent.tsx index f969cfcaa..f5cf51235 100644 --- a/client/src/app/[site]/components/SubHeader/Filters/FilterComponent.tsx +++ b/client/src/app/[site]/components/SubHeader/Filters/FilterComponent.tsx @@ -60,6 +60,8 @@ export function FilterComponent({ case "region": return t("Region"); case "city": return t("City"); case "device_type": return t("Device Type"); + case "device_model": return t("Device Model"); + case "app_version": return t("App Version"); case "operating_system": return t("Operating System"); case "operating_system_version": return t("Operating System Version"); case "browser": return t("Browser"); diff --git a/client/src/app/[site]/components/SubHeader/Filters/Filters.tsx b/client/src/app/[site]/components/SubHeader/Filters/Filters.tsx index 8e00af3da..daf86395b 100644 --- a/client/src/app/[site]/components/SubHeader/Filters/Filters.tsx +++ b/client/src/app/[site]/components/SubHeader/Filters/Filters.tsx @@ -17,6 +17,8 @@ export function Filters({ availableFilters }: { availableFilters?: FilterParamet switch (parameter) { case "country": return t("Country"); case "device_type": return t("Device Type"); + case "device_model": return t("Device Model"); + case "app_version": return t("App Version"); case "operating_system": return t("OS"); case "browser": return t("Browser"); case "referrer": return t("Referrer"); diff --git a/client/src/app/[site]/components/SubHeader/Filters/const.tsx b/client/src/app/[site]/components/SubHeader/Filters/const.tsx index 6a84de793..11d7d1140 100644 --- a/client/src/app/[site]/components/SubHeader/Filters/const.tsx +++ b/client/src/app/[site]/components/SubHeader/Filters/const.tsx @@ -108,6 +108,16 @@ export const FilterOptions: { value: "device_type", icon: , }, + { + label: "Device Model", + value: "device_model", + icon: , + }, + { + label: "App Version", + value: "app_version", + icon: , + }, { label: "Operating System", value: "operating_system", diff --git a/client/src/app/[site]/components/SubHeader/Filters/utils.ts b/client/src/app/[site]/components/SubHeader/Filters/utils.ts index 533ccac5d..22db228b7 100644 --- a/client/src/app/[site]/components/SubHeader/Filters/utils.ts +++ b/client/src/app/[site]/components/SubHeader/Filters/utils.ts @@ -7,6 +7,10 @@ export function getParameterNameLabel(parameter: FilterParameter) { return "Country"; case "device_type": return "Device Type"; + case "device_model": + return "Device Model"; + case "app_version": + return "App Version"; case "operating_system": return "OS"; case "browser": diff --git a/client/src/lib/filterGroups.ts b/client/src/lib/filterGroups.ts index ddbb1112c..18dd5f8b4 100644 --- a/client/src/lib/filterGroups.ts +++ b/client/src/lib/filterGroups.ts @@ -1,20 +1,10 @@ import { FilterParameter } from "@rybbit/shared"; -const BASE_FILTERS: FilterParameter[] = [ +const WEB_ONLY_FILTERS: FilterParameter[] = [ "hostname", - "browser", - "browser_version", - "operating_system", - "operating_system_version", - "language", - "country", - "region", - "city", - "device_type", - "referrer", - "page_title", "querystring", "channel", + "referrer", "utm_source", "utm_medium", "utm_campaign", @@ -27,66 +17,133 @@ const BASE_FILTERS: FilterParameter[] = [ "tag", ]; -export const SESSION_PAGE_FILTERS: FilterParameter[] = [...BASE_FILTERS, "pathname", "entry_page", "exit_page", "event_name"]; - -export const EVENT_FILTERS: FilterParameter[] = [ - ...BASE_FILTERS, - "pathname", - "page_title", - "event_name", - "entry_page", - "exit_page", -]; - -export const GOALS_PAGE_FILTERS: FilterParameter[] = [...BASE_FILTERS]; - -export const FUNNEL_PAGE_FILTERS: FilterParameter[] = [...BASE_FILTERS]; - -export const USER_PAGE_FILTERS: FilterParameter[] = [ - ...BASE_FILTERS, - "pathname", - "entry_page", - "exit_page", +const APP_ONLY_FILTERS: FilterParameter[] = [ + "device_model", + "app_version", ]; -export const JOURNEY_PAGE_FILTERS: FilterParameter[] = [ - "hostname", +const COMMON_FILTERS: FilterParameter[] = [ "browser", + "browser_version", "operating_system", + "operating_system_version", "language", "country", "region", "city", "device_type", - "referrer", - // "channel", - // "utm_source", - // "utm_medium", - // "utm_campaign", - // "utm_term", - // "utm_content", - "entry_page", - "exit_page", + "page_title", "dimensions", - "browser_version", - "operating_system_version", "user_id", "lat", "lon", ]; -export const SESSION_REPLAY_PAGE_FILTERS: FilterParameter[] = [ - "hostname", - "browser", - "browser_version", - "operating_system", - "operating_system_version", - "language", - "country", - "region", - "city", - "device_type", - "referrer", - "channel", - "user_id", -]; +function getBaseFilters(isApp: boolean): FilterParameter[] { + if (isApp) { + return [...APP_ONLY_FILTERS, ...COMMON_FILTERS]; + } + return [...WEB_ONLY_FILTERS, ...COMMON_FILTERS]; +} + +export function getSessionPageFilters(isApp: boolean): FilterParameter[] { + return [...getBaseFilters(isApp), "pathname", "entry_page", "exit_page", "event_name"]; +} + +export function getEventFilters(isApp: boolean): FilterParameter[] { + return [...getBaseFilters(isApp), "pathname", "page_title", "event_name", "entry_page", "exit_page"]; +} + +export function getGoalsPageFilters(isApp: boolean): FilterParameter[] { + return [...getBaseFilters(isApp)]; +} + +export function getFunnelPageFilters(isApp: boolean): FilterParameter[] { + return [...getBaseFilters(isApp)]; +} + +export function getUserPageFilters(isApp: boolean): FilterParameter[] { + const base: FilterParameter[] = [ + "browser", + "browser_version", + "operating_system", + "operating_system_version", + "language", + "country", + "region", + "city", + "device_type", + "user_id", + ]; + if (isApp) { + return ["device_model", "app_version", ...base, "pathname", "entry_page", "exit_page"]; + } + return ["hostname", "referrer", ...base, "pathname", "entry_page", "exit_page"]; +} + +export function getJourneyPageFilters(isApp: boolean): FilterParameter[] { + const base: FilterParameter[] = [ + "browser", + "operating_system", + "language", + "country", + "region", + "city", + "device_type", + "entry_page", + "exit_page", + "dimensions", + "browser_version", + "operating_system_version", + "user_id", + "lat", + "lon", + ]; + if (isApp) { + return ["device_model", "app_version", ...base]; + } + return [ + "hostname", + "referrer", + // "channel", + // "utm_source", + // "utm_medium", + // "utm_campaign", + // "utm_term", + // "utm_content", + ...base, + ]; +} + +export function getSessionReplayPageFilters(isApp: boolean): FilterParameter[] { + const base: FilterParameter[] = [ + "browser", + "browser_version", + "operating_system", + "operating_system_version", + "language", + "country", + "region", + "city", + "device_type", + "user_id", + ]; + if (isApp) { + return ["device_model", "app_version", ...base]; + } + return ["hostname", "referrer", "channel", ...base]; +} + +// Static exports used by API hooks - include ALL filters (web + app union) +// so that any active filter passes through regardless of site type. +// The SubHeader UI uses the dynamic getter functions above to show only relevant filters. +function allFilters(...arrays: FilterParameter[][]): FilterParameter[] { + return [...new Set(arrays.flat())]; +} +export const SESSION_PAGE_FILTERS: FilterParameter[] = allFilters(getSessionPageFilters(false), getSessionPageFilters(true)); +export const EVENT_FILTERS: FilterParameter[] = allFilters(getEventFilters(false), getEventFilters(true)); +export const GOALS_PAGE_FILTERS: FilterParameter[] = allFilters(getGoalsPageFilters(false), getGoalsPageFilters(true)); +export const FUNNEL_PAGE_FILTERS: FilterParameter[] = allFilters(getFunnelPageFilters(false), getFunnelPageFilters(true)); +export const USER_PAGE_FILTERS: FilterParameter[] = allFilters(getUserPageFilters(false), getUserPageFilters(true)); +export const JOURNEY_PAGE_FILTERS: FilterParameter[] = allFilters(getJourneyPageFilters(false), getJourneyPageFilters(true)); +export const SESSION_REPLAY_PAGE_FILTERS: FilterParameter[] = allFilters(getSessionReplayPageFilters(false), getSessionReplayPageFilters(true)); diff --git a/shared/src/filters.ts b/shared/src/filters.ts index 7339ffbea..413884e28 100644 --- a/shared/src/filters.ts +++ b/shared/src/filters.ts @@ -16,6 +16,8 @@ export type FilterParameter = | "region" | "city" | "device_type" + | "device_model" + | "app_version" | "referrer" | "hostname" | "pathname" From ed6cfc3569bda631f76316836e169cddf0089bd2 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:20:42 +0100 Subject: [PATCH 05/18] feat: AddSite dialog with web/app platform selector --- client/src/app/components/AddSite.tsx | 59 ++++++++++++++++++++------- client/src/app/page.tsx | 1 + client/src/components/Favicon.tsx | 25 +++++++++++- client/src/components/SiteCard.tsx | 13 ++++-- client/src/lib/utils.ts | 4 ++ 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/client/src/app/components/AddSite.tsx b/client/src/app/components/AddSite.tsx index c6c122226..911ea1020 100644 --- a/client/src/app/components/AddSite.tsx +++ b/client/src/app/components/AddSite.tsx @@ -1,6 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { AlertCircle, AppWindow, Plus } from "lucide-react"; +import { AlertCircle, AppWindow, Plus, Smartphone } from "lucide-react"; +import { DateTime } from "luxon"; import { useExtracted } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -23,8 +24,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../../components/ui/too import { authClient } from "../../lib/auth"; import { IS_CLOUD } from "../../lib/const"; import { resetStore, useStore } from "../../lib/store"; -import { useStripeSubscription } from "../../lib/subscription/useStripeSubscription"; -import { isValidDomain, normalizeDomain } from "../../lib/utils"; +import { SubscriptionData, useStripeSubscription } from "../../lib/subscription/useStripeSubscription"; +import { isValidDomain, isValidPackageName, normalizeDomain } from "../../lib/utils"; export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disabled?: boolean }) { const { setSite } = useStore(); @@ -46,6 +47,7 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa const [isPublic, setIsPublic] = useState(false); const [saltUserIds, setSaltUserIds] = useState(false); const [error, setError] = useState(""); + const [siteType, setSiteType] = useState<"web" | "app">("web"); const handleSubmit = async () => { setError(""); @@ -55,18 +57,25 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa return; } - // Validate before attempting to add - if (!isValidDomain(domain)) { - setError(t("Invalid domain format. Must be a valid domain like example.com or sub.example.com")); - return; + if (siteType === "web") { + if (!isValidDomain(domain)) { + setError(t("Invalid domain format. Must be a valid domain like example.com or sub.example.com")); + return; + } + } else { + if (!isValidPackageName(domain)) { + setError(t("Invalid package name format. Must be like com.example.app")); + return; + } } try { - const normalizedDomain = normalizeDomain(domain); - const siteName = name.trim() || normalizedDomain; - const site = await addSite(normalizedDomain, siteName, activeOrganization.id, { + const normalizedValue = siteType === "web" ? normalizeDomain(domain) : domain.trim(); + const siteName = name.trim() || normalizedValue; + const site = await addSite(normalizedValue, siteName, activeOrganization.id, { isPublic, saltUserIds, + type: siteType, }); resetStore(); @@ -87,6 +96,7 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa setError(""); setIsPublic(false); setSaltUserIds(false); + setSiteType("web"); }; @@ -149,22 +159,43 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa - - {t("Add Website")} + {siteType === "web" ? : } + {siteType === "web" ? t("Add Website") : t("Add App")} {t("Track analytics for a new website in your organization")}
+
+ +
+ {([ + { value: "web" as const, label: t("Web"), icon: AppWindow }, + { value: "app" as const, label: t("App"), icon: Smartphone }, + ]).map(({ value, label, icon: Icon }) => ( + + ))} +
+
setDomain(e.target.value.toLowerCase())} - placeholder="example.com or sub.example.com" + placeholder={siteType === "web" ? "example.com or sub.example.com" : "com.example.app"} />
diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index 2d541f2d6..f7f9d6104 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -201,6 +201,7 @@ export default function Home() { onTagsUpdated={refetchSites} selectedTags={selectedTags} onTagClick={handleTagClick} + siteType={site.type} /> ); })} diff --git a/client/src/components/Favicon.tsx b/client/src/components/Favicon.tsx index 6c65f8e7b..57cfbcdd0 100644 --- a/client/src/components/Favicon.tsx +++ b/client/src/components/Favicon.tsx @@ -1,10 +1,33 @@ +import { Smartphone } from "lucide-react"; import { useState } from "react"; import { cn } from "../lib/utils"; -export function Favicon({ domain, className }: { domain: string; className?: string }) { +export function Favicon({ + domain, + className, + siteType, +}: { + domain: string; + className?: string; + siteType?: "web" | "app"; +}) { const [imageError, setImageError] = useState(false); const firstLetter = domain.charAt(0).toUpperCase(); + if (siteType && siteType !== "web") { + const Icon = Smartphone; + return ( +
+ +
+ ); + } + if (imageError) { return (
void; selectedTags?: string[]; onTagClick?: (tag: string) => void; + siteType?: "web" | "app"; } -export function SiteCard({ siteId, name, domain, tags = [], allTags = [], onTagsUpdated, selectedTags = [], onTagClick }: SiteCardProps) { +export function SiteCard({ siteId, name, domain, tags = [], allTags = [], onTagsUpdated, selectedTags = [], onTagClick, siteType }: SiteCardProps) { const t = useExtracted(); const { ref, isInView } = useInView({ // Start loading slightly before the card comes into view @@ -105,8 +106,14 @@ export function SiteCard({ siteId, name, domain, tags = [], allTags = [], onTags ) : ( <>
- + {name} + {siteType && siteType !== "web" && ( + + + {t("App")} + + )}
e.preventDefault()}> | null; user_id?: string }, >(data: T) { From 07c1cf54d5de815f8de9cd5278fdda7cbe8981f4 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:20:48 +0100 Subject: [PATCH 06/18] feat: adapt dashboard UI for app site types --- .../app/[site]/components/Header/NoData.tsx | 80 +++++++++---- .../app/[site]/components/Sidebar/Sidebar.tsx | 29 +++-- .../components/Sidebar/SiteSelector.tsx | 9 +- .../components/MainSection/MainSection.tsx | 7 +- .../main/components/MainSection/Overview.tsx | 7 +- .../main/components/sections/Devices.tsx | 111 ++++++++++++------ .../main/components/sections/Events.tsx | 15 ++- .../[site]/main/components/sections/Pages.tsx | 66 +++++------ .../main/components/sections/Weekdays.tsx | 11 +- client/src/app/[site]/main/page.tsx | 15 ++- .../[site]/pages/components/PageListItem.tsx | 18 ++- 11 files changed, 235 insertions(+), 133 deletions(-) diff --git a/client/src/app/[site]/components/Header/NoData.tsx b/client/src/app/[site]/components/Header/NoData.tsx index 081289ccc..89927e5ac 100644 --- a/client/src/app/[site]/components/Header/NoData.tsx +++ b/client/src/app/[site]/components/Header/NoData.tsx @@ -6,6 +6,7 @@ import { SiContentful, SiDocusaurus, SiDrupal, + SiFlutter, SiFramer, SiGatsby, SiGhost, @@ -74,6 +75,9 @@ export function NoData() { const { data: siteHasData, isLoading } = useSiteHasData(site); const { data: siteMetadata, isLoading: isLoadingSiteMetadata } = useGetSite(site); + const siteType = siteMetadata?.type || "web"; + const isApp = siteType !== "web"; + if (!siteHasData && !isLoading && !isLoadingSiteMetadata) { return ( <> @@ -86,28 +90,58 @@ export function NoData() {
{t("Waiting for analytics from {name}...", { name: siteMetadata?.name ?? "" })}
-
{t("Place this snippet in the {headTag} of your website:", { headTag: "" })}
- `} - className="text-xs" - /> - - {t("See our")}{" "} - - {t("docs")} - {" "} - {t("for more information, or")}{" "} - - {t("troubleshoot")} - {" "} - {t("if your script isn't sending traffic.")} - - {/* {siteMetadata?.siteId && } */} - {/* Framework Guide Cards */} -
-

{t("Platform Guides")}

-
+ {isApp ? ( + <> +
{t("Add the Rybbit SDK to your app:")}
+ + + {t("See the")}{" "} + + {t("Flutter SDK documentation")} + {" "} + {t("for installation and usage instructions.")} + + {/* SDK Guide Cards */} +
+

{t("Platform Guides")}

+
+ } + title="Flutter" + description="" + href="https://github.com/nks-hub/rybbit-flutter" + /> +
+
+ + ) : ( + <> +
{t("Place this snippet in the {headTag} of your website:", { headTag: "" })}
+ `} + className="text-xs" + /> + + {t("See our")}{" "} + + {t("docs")} + {" "} + {t("for more information, or")}{" "} + + {t("troubleshoot")} + {" "} + {t("if your script isn't sending traffic.")} + + {/* {siteMetadata?.siteId && } */} + {/* Framework Guide Cards */} +
+

{t("Platform Guides")}

+
} title="Google Tag Manager" @@ -321,6 +355,8 @@ export function NoData() { />
+ + )}
diff --git a/client/src/app/[site]/components/Sidebar/Sidebar.tsx b/client/src/app/[site]/components/Sidebar/Sidebar.tsx index af0fd1da6..c834601f7 100644 --- a/client/src/app/[site]/components/Sidebar/Sidebar.tsx +++ b/client/src/app/[site]/components/Sidebar/Sidebar.tsx @@ -34,6 +34,7 @@ function SidebarContent() { const embed = useEmbedablePage(); const { data: site } = useGetSite(Number(pathname.split("/")[1])); + const isApp = site?.type === "app"; // Check which tab is active based on the current path const getTabPath = (tabName: string) => { @@ -70,7 +71,7 @@ function SidebarContent() {
- {t("Web Analytics")} + {isApp ? t("Analytics") : t("Web Analytics")} {IS_CLOUD && ( } /> )} - {IS_CLOUD && ( + {IS_CLOUD && !isApp && (
{t("Product Analytics")} -
- {!subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading && ( - } - /> - )} -
+ {!isApp && ( +
+ {!subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading && ( + } + /> + )} +
+ )} void }) { )} >
- +
{site.name}
+ {site.type && site.type !== "web" && ( + + )}
@@ -155,7 +158,7 @@ function SiteSelectorWrapper() { {site ? ( diff --git a/client/src/app/[site]/main/components/MainSection/MainSection.tsx b/client/src/app/[site]/main/components/MainSection/MainSection.tsx index 412b558ac..44089908a 100644 --- a/client/src/app/[site]/main/components/MainSection/MainSection.tsx +++ b/client/src/app/[site]/main/components/MainSection/MainSection.tsx @@ -6,6 +6,7 @@ import { useExtracted } from "next-intl"; import Link from "next/link"; import { useGetOverview } from "../../../../../api/analytics/hooks/useGetOverview"; import { useGetOverviewBucketed } from "../../../../../api/analytics/hooks/useGetOverviewBucketed"; +import { useGetSite } from "../../../../../api/admin/hooks/useSites"; import { BucketSelection } from "../../../../../components/BucketSelection"; import { RybbitTextLogo } from "../../../../../components/RybbitLogo"; import { useWhiteLabel } from "../../../../../hooks/useIsWhiteLabel"; @@ -26,14 +27,16 @@ export function MainSection() { const { isWhiteLabel } = useWhiteLabel(); const session = authClient.useSession(); const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { selectedStat, time, site, bucket } = useStore(); const getSelectedStatLabel = () => { switch (selectedStat) { - case "pageviews": return t("Pageviews"); + case "pageviews": return isApp ? t("Screenviews") : t("Pageviews"); case "sessions": return t("Sessions"); - case "pages_per_session": return t("Pages per Session"); + case "pages_per_session": return isApp ? t("Screens per Session") : t("Pages per Session"); case "bounce_rate": return t("Bounce Rate"); case "session_duration": return t("Session Duration"); case "users": return t("Users"); diff --git a/client/src/app/[site]/main/components/MainSection/Overview.tsx b/client/src/app/[site]/main/components/MainSection/Overview.tsx index 013345700..a975058e6 100644 --- a/client/src/app/[site]/main/components/MainSection/Overview.tsx +++ b/client/src/app/[site]/main/components/MainSection/Overview.tsx @@ -9,6 +9,7 @@ import { useExtracted } from "next-intl"; import { useState } from "react"; import { useGetOverview } from "../../../../../api/analytics/hooks/useGetOverview"; import { useGetOverviewBucketed } from "../../../../../api/analytics/hooks/useGetOverviewBucketed"; +import { useGetSite } from "../../../../../api/admin/hooks/useSites"; import { StatType, useStore } from "../../../../../lib/store"; import { SparklinesChart } from "./SparklinesChart"; @@ -156,6 +157,8 @@ const Stat = ({ export function Overview() { const { site } = useStore(); const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; // Current period - automatically handles both regular time-based and past-minutes queries const { @@ -198,14 +201,14 @@ export function Overview() { ("browsers"); const [expanded, setExpanded] = useState(false); const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const close = () => { setExpanded(false); }; @@ -28,7 +31,7 @@ export function Devices() {
- {t("Browsers")} + {isApp ? t("App Versions") : t("Browsers")} {t("Devices")} {t("Operating Systems")} {t("Screen Dimensions")} @@ -41,46 +44,80 @@ export function Devices() {
- e.value} - getKey={e => e.value} - getLabel={e => ( -
- - {e.value || t("Other")} -
- )} - expanded={expanded} - close={close} - /> + {isApp ? ( + e.value} + getKey={e => e.value} + getLabel={e => ( +
+ + {e.value || t("Other")} +
+ )} + expanded={expanded} + close={close} + /> + ) : ( + e.value} + getKey={e => e.value} + getLabel={e => ( +
+ + {e.value || t("Other")} +
+ )} + expanded={expanded} + close={close} + /> + )}
- e.value} - getKey={e => e.value} - getLabel={e => ( -
- - {e.value || t("Other")} -
- )} - getSubrowLabel={e => { - const justBrowser = e.value.split(" ").slice(0, -1).join(" "); - return ( + {isApp ? ( + e.value} + getKey={e => e.value} + getLabel={e => (
- + {e.value || t("Other")}
- ); - }} - expanded={expanded} - close={close} - hasSubrow={true} - /> + )} + expanded={expanded} + close={close} + /> + ) : ( + e.value} + getKey={e => e.value} + getLabel={e => ( +
+ + {e.value || t("Other")} +
+ )} + getSubrowLabel={e => { + const justBrowser = e.value.split(" ").slice(0, -1).join(" "); + return ( +
+ + {e.value || t("Other")} +
+ ); + }} + expanded={expanded} + close={close} + hasSubrow={true} + /> + )}
("events"); const [expandedOutbound, setExpandedOutbound] = useState(false); const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; return ( @@ -73,10 +76,10 @@ export function Events() {
{t("Custom Events")} - {t("Outbound Links")} + {!isApp && {t("Outbound Links")}}
- {tab === "outbound" && ( + {tab === "outbound" && !isApp && ( @@ -85,9 +88,11 @@ export function Events() { - - setExpandedOutbound(false)} /> - + {!isApp && ( + + setExpandedOutbound(false)} /> + + )}
diff --git a/client/src/app/[site]/main/components/sections/Pages.tsx b/client/src/app/[site]/main/components/sections/Pages.tsx index 01119203b..48c747ca8 100644 --- a/client/src/app/[site]/main/components/sections/Pages.tsx +++ b/client/src/app/[site]/main/components/sections/Pages.tsx @@ -18,10 +18,18 @@ export function Pages() { const [tab, setTab] = useState("pages"); const [expanded, setExpanded] = useState(false); const t = useExtracted(); + const isApp = siteMetadata?.type === "app"; const close = () => { setExpanded(false); }; + const getPageLink = isApp + ? undefined + : (e: { value: string; hostname?: string }) => { + const host = e.hostname || siteMetadata?.domain; + return host ? `https://${host}${e.value}` : "#"; + }; + return ( @@ -29,11 +37,11 @@ export function Pages() {
- {t("Pages")} + {isApp ? t("Screens") : t("Pages")} {t("Titles")} - {t("Entries")} - {t("Exits")} - {t("Hostnames")} + {isApp ? t("Entry Screens") : t("Entries")} + {isApp ? t("Exit Screens") : t("Exits")} + {!isApp && {t("Hostnames")}}
@@ -45,14 +53,11 @@ export function Pages() { e.value} getKey={e => e.value} getLabel={e => truncateString(e.value, 50) || t("Other")} - getLink={e => { - const host = e.hostname || siteMetadata?.domain; - return host ? `https://${host}${e.value}` : "#"; - }} + getLink={getPageLink} expanded={expanded} close={close} /> @@ -64,11 +69,6 @@ export function Pages() { getValue={e => e.value} getKey={e => e.value} getLabel={e => truncateString(e.value, 50) || t("Other")} - // getLink={(e) => - // e.pathname - // ? `https://${siteMetadata?.domain}${e.pathname}` - // : "#" - // } expanded={expanded} close={close} /> @@ -76,14 +76,11 @@ export function Pages() { e.value} getKey={e => e.value} getLabel={e => e.value || t("Other")} - getLink={e => { - const host = e.hostname || siteMetadata?.domain; - return host ? `https://${host}${e.value}` : "#"; - }} + getLink={getPageLink} expanded={expanded} close={close} /> @@ -91,29 +88,28 @@ export function Pages() { e.value} getKey={e => e.value} getLabel={e => e.value || t("Other")} - getLink={e => { - const host = e.hostname || siteMetadata?.domain; - return host ? `https://${host}${e.value}` : "#"; - }} - expanded={expanded} - close={close} - /> - - - e.value} - getKey={e => e.value} - getLabel={e => e.value} + getLink={getPageLink} expanded={expanded} close={close} /> + {!isApp && ( + + e.value} + getKey={e => e.value} + getLabel={e => e.value} + expanded={expanded} + close={close} + /> + + )} diff --git a/client/src/app/[site]/main/components/sections/Weekdays.tsx b/client/src/app/[site]/main/components/sections/Weekdays.tsx index 185c8c8e5..6aa2839d6 100644 --- a/client/src/app/[site]/main/components/sections/Weekdays.tsx +++ b/client/src/app/[site]/main/components/sections/Weekdays.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardLoader } from "../../../../../components/ui/card import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../../components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../../components/ui/tooltip"; import { getTimezone, StatType, useStore } from "../../../../../lib/store"; +import { useGetSite } from "../../../../../api/admin/hooks/useSites"; import { cn } from "../../../../../lib/utils"; import { formatLocalTime, hourLabels, longDayNames, shortDayNames } from "../../../../../lib/dateTimeUtils"; @@ -17,6 +18,8 @@ export function Weekdays() { const [metric, setMetric] = useState("users"); const timezone = getTimezone(); const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { data, isFetching, error } = useGetOverviewBucketed({ site, @@ -128,13 +131,13 @@ export function Weekdays() { case "users": return t("Unique Visitors"); case "pageviews": - return t("Pageviews"); + return isApp ? t("Screenviews") : t("Pageviews"); case "sessions": return t("Sessions"); case "bounce_rate": return t("Bounce Rate"); case "pages_per_session": - return t("Pages per Session"); + return isApp ? t("Screens per Session") : t("Pages per Session"); case "session_duration": return t("Session Duration"); default: @@ -159,10 +162,10 @@ export function Weekdays() { {t("Unique Visitors")} - {t("Pageviews")} + {isApp ? t("Screenviews") : t("Pageviews")} {t("Sessions")} {t("Bounce Rate")} - {t("Pages per Session")} + {isApp ? t("Screens per Session") : t("Pages per Session")} {t("Session Duration")} diff --git a/client/src/app/[site]/main/page.tsx b/client/src/app/[site]/main/page.tsx index a6d2909ac..1273120bf 100644 --- a/client/src/app/[site]/main/page.tsx +++ b/client/src/app/[site]/main/page.tsx @@ -1,5 +1,6 @@ "use client"; import { ReactNode } from "react"; +import { useGetSite } from "../../../api/admin/hooks/useSites"; import { useGetLiveUserCount } from "../../../api/analytics/hooks/useGetLiveUserCount"; import { useInView } from "../../../hooks/useInView"; import { useSetPageTitle } from "../../../hooks/useSetPageTitle"; @@ -37,6 +38,8 @@ export default function MainPage() { function MainPageContent() { const { data } = useGetLiveUserCount(5); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; useSetPageTitle(`${data?.count ?? "…"} user${data?.count === 1 ? "" : "s"} online`); @@ -45,14 +48,18 @@ function MainPageContent() {
- + {!isApp && } - - {IS_CLOUD && } - {IS_CLOUD && } + +
+ +
+
+ {IS_CLOUD && !isApp && } + {IS_CLOUD && !isApp && }
); diff --git a/client/src/app/[site]/pages/components/PageListItem.tsx b/client/src/app/[site]/pages/components/PageListItem.tsx index 512919a2a..bb4f213f9 100644 --- a/client/src/app/[site]/pages/components/PageListItem.tsx +++ b/client/src/app/[site]/pages/components/PageListItem.tsx @@ -30,6 +30,8 @@ export function PageListItem({ pageData, isLoading = false }: PageListItemProps) const [thumbnailError, setThumbnailError] = useState(false); const { data: siteMetadata } = useGetSite(); const { site, time, bucket } = useStore(); // Get time and bucket from store + const siteType = siteMetadata?.type || "web"; + const isApp = siteType !== "web"; const isPastMinutesMode = time.mode === "past-minutes"; @@ -71,14 +73,18 @@ export function PageListItem({ pageData, isLoading = false }: PageListItemProps) const isLoadingTrafficData = isPastMinutesMode ? isLoadingPastMinutes : isLoadingRegular; // External URL for the page - const pageUrl = pageData.hostname - ? `https://${pageData.hostname}${pageData.value}` - : siteMetadata?.domain - ? `https://${siteMetadata.domain}${pageData.value}` - : ""; + const pageUrl = isApp + ? "" + : pageData.hostname + ? `https://${pageData.hostname}${pageData.value}` + : siteMetadata?.domain + ? `https://${siteMetadata.domain}${pageData.value}` + : ""; // Fetch page metadata using TanStack Query - const { data: metadata, isLoading: isLoadingMetadata, isError: isMetadataError } = usePageMetadata(pageUrl); + const { data: metadata, isLoading: isLoadingMetadata, isError: isMetadataError } = usePageMetadata( + isApp ? "" : pageUrl + ); // Get thumbnail URL from metadata const thumbnailUrl = !thumbnailError && !isMetadataError ? metadata?.image : null; From c774bbd0ad41b7859eb507cb44292df1a24e9e9b Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:33:30 +0100 Subject: [PATCH 07/18] feat: expose app_version and device_model in analytics endpoints --- server/src/api/analytics/events/getEvents.ts | 6 +++++- server/src/api/analytics/sessions/getSession.ts | 4 ++++ server/src/api/analytics/sessions/getSessions.ts | 4 ++++ server/src/api/analytics/users/getUserInfo.ts | 6 ++++++ server/src/api/analytics/users/getUsers.ts | 4 ++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/server/src/api/analytics/events/getEvents.ts b/server/src/api/analytics/events/getEvents.ts index 4f057b908..f94ca40f6 100644 --- a/server/src/api/analytics/events/getEvents.ts +++ b/server/src/api/analytics/events/getEvents.ts @@ -30,6 +30,8 @@ export type GetEventsResponse = { device_type: string; type: string; page_title: string; + device_model: string; + app_version: string; }[]; interface GetEventsRequest { @@ -68,7 +70,9 @@ const EVENT_COLUMNS = ` screen_width, screen_height, device_type, - type + type, + device_model, + app_version `; const EVENT_TYPE_FILTER = `AND type IN ('custom_event', 'pageview', 'outbound', 'button_click', 'copy', 'form_submit', 'input_change')`; diff --git a/server/src/api/analytics/sessions/getSession.ts b/server/src/api/analytics/sessions/getSession.ts index e0d115b29..f159feae3 100644 --- a/server/src/api/analytics/sessions/getSession.ts +++ b/server/src/api/analytics/sessions/getSession.ts @@ -10,6 +10,8 @@ export interface SessionDetails { city: string; language: string; device_type: string; + device_model: string; + app_version: string; browser: string; browser_version: string; operating_system: string; @@ -86,6 +88,8 @@ SELECT any(city) as city, any(language) as language, any(device_type) as device_type, + any(device_model) as device_model, + any(app_version) as app_version, any(browser) as browser, any(browser_version) as browser_version, any(operating_system) as operating_system, diff --git a/server/src/api/analytics/sessions/getSessions.ts b/server/src/api/analytics/sessions/getSessions.ts index ccb1bc506..7e29c8e56 100644 --- a/server/src/api/analytics/sessions/getSessions.ts +++ b/server/src/api/analytics/sessions/getSessions.ts @@ -20,6 +20,8 @@ export type GetSessionsResponse = { operating_system_version: string; screen_width: number; screen_height: number; + device_model: string; + app_version: string; referrer: string; channel: string; hostname: string; @@ -120,6 +122,8 @@ export async function getSessions(req: FastifyRequest, res: argMax(operating_system_version, timestamp) AS operating_system_version, argMax(screen_width, timestamp) AS screen_width, argMax(screen_height, timestamp) AS screen_height, + argMax(device_model, timestamp) AS device_model, + argMax(app_version, timestamp) AS app_version, argMin(referrer, timestamp) AS referrer, argMin(channel, timestamp) AS channel, argMin(hostname, timestamp) AS hostname, diff --git a/server/src/api/analytics/users/getUserInfo.ts b/server/src/api/analytics/users/getUserInfo.ts index abcb32b39..2a35e434a 100644 --- a/server/src/api/analytics/users/getUserInfo.ts +++ b/server/src/api/analytics/users/getUserInfo.ts @@ -15,6 +15,8 @@ interface UserPageviewData { city: string; language: string; device_type: string; + device_model: string; + app_version: string; browser: string; browser_version: string; operating_system: string; @@ -67,6 +69,8 @@ export async function getUserInfo( argMax(city, timestamp) AS city, argMax(language, timestamp) AS language, argMax(device_type, timestamp) AS device_type, + argMax(device_model, timestamp) AS device_model, + argMax(app_version, timestamp) AS app_version, argMax(browser, timestamp) AS browser, argMax(browser_version, timestamp) AS browser_version, argMax(operating_system, timestamp) AS operating_system, @@ -103,6 +107,8 @@ export async function getUserInfo( any(city) AS city, any(language) AS language, any(device_type) AS device_type, + any(device_model) AS device_model, + any(app_version) AS app_version, any(browser) AS browser, any(browser_version) AS browser_version, any(operating_system) AS operating_system, diff --git a/server/src/api/analytics/users/getUsers.ts b/server/src/api/analytics/users/getUsers.ts index 229d15700..3c4730170 100644 --- a/server/src/api/analytics/users/getUsers.ts +++ b/server/src/api/analytics/users/getUsers.ts @@ -17,6 +17,8 @@ export type GetUsersResponse = { browser: string; operating_system: string; device_type: string; + device_model: string; + app_version: string; pageviews: number; events: number; sessions: number; @@ -116,6 +118,8 @@ WITH AggregatedUsers AS ( argMax(operating_system, timestamp) AS operating_system, argMax(operating_system_version, timestamp) AS operating_system_version, argMax(device_type, timestamp) AS device_type, + argMax(device_model, timestamp) AS device_model, + argMax(app_version, timestamp) AS app_version, argMax(screen_width, timestamp) AS screen_width, argMax(screen_height, timestamp) AS screen_height, argMin(referrer, timestamp) AS referrer, From 1767d6c2e03d0d1b51164a6778baf9accd1e18aa Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:33:35 +0100 Subject: [PATCH 08/18] feat: adapt events page and event log for app sites --- .../components/EventLog/EventDetailsSheet.tsx | 70 ++++++++++++------- .../events/components/EventLog/EventRow.tsx | 49 +++++++------ .../components/EventLog/eventLogUtils.tsx | 5 +- .../events/components/EventTypesChart.tsx | 32 +++++---- client/src/app/[site]/events/page.tsx | 7 +- client/src/components/EventTypeFilter.tsx | 5 +- 6 files changed, 107 insertions(+), 61 deletions(-) diff --git a/client/src/app/[site]/events/components/EventLog/EventDetailsSheet.tsx b/client/src/app/[site]/events/components/EventLog/EventDetailsSheet.tsx index 9d8e920b7..6efbe4ae2 100644 --- a/client/src/app/[site]/events/components/EventLog/EventDetailsSheet.tsx +++ b/client/src/app/[site]/events/components/EventLog/EventDetailsSheet.tsx @@ -18,6 +18,7 @@ import { CountryFlag } from "../../../components/shared/icons/CountryFlag"; import { DeviceIcon } from "../../../components/shared/icons/Device"; import { OperatingSystem } from "../../../components/shared/icons/OperatingSystem"; import { buildEventPath, getEventTypeLabel, parseEventProperties } from "./eventLogUtils"; +import { useGetSite } from "../../../../../api/admin/hooks/useSites"; interface EventDetailsSheetProps { open: boolean; @@ -28,6 +29,8 @@ interface EventDetailsSheetProps { export function EventDetailsSheet({ open, onOpenChange, event, site }: EventDetailsSheetProps) { const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const selectedEventProperties = event ? parseEventProperties(event) : {}; const sessionQuery = useQuery({ @@ -61,7 +64,7 @@ export function EventDetailsSheet({ open, onOpenChange, event, site }: EventDeta
- {getEventTypeLabel(event?.type || "", t)} + {getEventTypeLabel(event?.type || "", t, isApp)}
@@ -100,29 +103,55 @@ export function EventDetailsSheet({ open, onOpenChange, event, site }: EventDeta {t("Session ID")} {truncateString(event.session_id, 24)}
+ {!isApp && ( +
+ {t("Hostname")} + {event.hostname || "-"} +
+ )}
- {t("Hostname")} - {event.hostname || "-"} -
-
- {t("Path")} + {isApp ? t("Screen") : t("Path")} {buildEventPath(event) || "-"}
-
- {t("Referrer")} - {event.referrer || "-"} -
+ {!isApp && ( +
+ {t("Referrer")} + {event.referrer || "-"} +
+ )}
-
- {t("Browser")} - - - {event.browser || t("Unknown")}{event.browser_version ? ` ${event.browser_version}` : ""} - -
+ {isApp ? ( + <> +
+ {t("Device Model")} + {event.device_model || "-"} +
+
+ {t("App Version")} + {event.app_version ? `v${event.app_version}` : "-"} +
+ + ) : ( + <> +
+ {t("Browser")} + + + {event.browser || t("Unknown")}{event.browser_version ? ` ${event.browser_version}` : ""} + +
+
+ {t("Device")} + + + {event.device_type || t("Unknown")} + +
+ + )}
{t("Operating System")} @@ -130,13 +159,6 @@ export function EventDetailsSheet({ open, onOpenChange, event, site }: EventDeta {event.operating_system || t("Unknown")}{event.operating_system_version ? ` ${event.operating_system_version}` : ""}
-
- {t("Device")} - - - {event.device_type || t("Unknown")} - -
{t("Screen")} {event.screen_width && event.screen_height ? `${event.screen_width} × ${event.screen_height}` : "-"} diff --git a/client/src/app/[site]/events/components/EventLog/EventRow.tsx b/client/src/app/[site]/events/components/EventLog/EventRow.tsx index 29d9b44a8..da27479b1 100644 --- a/client/src/app/[site]/events/components/EventLog/EventRow.tsx +++ b/client/src/app/[site]/events/components/EventLog/EventRow.tsx @@ -15,6 +15,7 @@ import { CountryFlag } from "../../../components/shared/icons/CountryFlag"; import { OperatingSystem } from "../../../components/shared/icons/OperatingSystem"; import { buildEventPath, getEventTypeLabel, getMainData, parseEventProperties } from "./eventLogUtils"; import { DeviceIcon } from "../../../components/shared/icons/Device"; +import { useGetSite } from "../../../../../api/admin/hooks/useSites"; interface EventRowProps { event: Event; @@ -24,6 +25,8 @@ interface EventRowProps { export function EventRow({ event, site, onClick }: EventRowProps) { const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { locale, hour12, formatRelative } = useDateTimeFormat(); const eventProperties = parseEventProperties(event); const eventTime = DateTime.fromSQL(event.timestamp, { zone: "utc" }) @@ -53,7 +56,7 @@ export function EventRow({ event, site, onClick }: EventRowProps) {
- {getEventTypeLabel(event.type, t)} + {getEventTypeLabel(event.type, t, isApp)}
@@ -93,16 +96,18 @@ export function EventRow({ event, site, onClick }: EventRowProps) { )} - - -
- -
-
- -

{event.browser || t("Unknown browser")}

-
-
+ {!isApp && ( + + +
+ +
+
+ +

{event.browser || t("Unknown browser")}

+
+
+ )}
@@ -113,16 +118,18 @@ export function EventRow({ event, site, onClick }: EventRowProps) {

{event.operating_system || t("Unknown OS")}

- - -
- -
-
- -

{event.device_type || t("Unknown device")}

-
-
+ {!isApp && ( + + +
+ +
+
+ +

{event.device_type || t("Unknown device")}

+
+
+ )}
diff --git a/client/src/app/[site]/events/components/EventLog/eventLogUtils.tsx b/client/src/app/[site]/events/components/EventLog/eventLogUtils.tsx index 0fef3e8ce..395c6f418 100644 --- a/client/src/app/[site]/events/components/EventLog/eventLogUtils.tsx +++ b/client/src/app/[site]/events/components/EventLog/eventLogUtils.tsx @@ -18,8 +18,11 @@ export function parseEventProperties(event: Event): Record { return {}; } -export function getEventTypeLabel(type: string, t?: TranslationFunction) { +export function getEventTypeLabel(type: string, t?: TranslationFunction, isApp?: boolean) { const label = EVENT_TYPE_CONFIG.find(item => item.value === type)?.label ?? "Event"; + if (isApp && label === "Pageview") { + return t ? t("Screenview") : "Screenview"; + } return t ? t(label) : label; } diff --git a/client/src/app/[site]/events/components/EventTypesChart.tsx b/client/src/app/[site]/events/components/EventTypesChart.tsx index d6ca7b356..5f98de29f 100644 --- a/client/src/app/[site]/events/components/EventTypesChart.tsx +++ b/client/src/app/[site]/events/components/EventTypesChart.tsx @@ -13,6 +13,7 @@ import { formatChartDateTime, hour12, userLocale } from "@/lib/dateTimeUtils"; import { useNivoTheme } from "@/lib/nivo"; import { getTimezone, useStore } from "@/lib/store"; import { formatter } from "@/lib/utils"; +import { useGetSite } from "../../../../api/admin/hooks/useSites"; import { CardLoader } from "../../../../components/ui/card"; const EVENT_TYPE_CONFIG = [ @@ -27,6 +28,21 @@ const EVENT_TYPE_CONFIG = [ { key: "input_change_count", label: "Input Changes", color: "#f472b6" }, ] as const; +// Translated labels keyed by the raw label +function getTranslatedEventTypeLabels(t: (key: string) => string, isApp: boolean): Record { + return { + Pageviews: isApp ? t("Screenviews") : t("Pageviews"), + "Custom Events": t("Custom Events"), + Performance: t("Performance"), + Outbound: t("Outbound"), + Errors: t("Errors"), + "Button Clicks": t("Button Clicks"), + Copy: t("Copy"), + "Form Submits": t("Form Submits"), + "Input Changes": t("Input Changes"), + }; +} + type EventTypeKey = (typeof EVENT_TYPE_CONFIG)[number]["key"]; type DataPoint = { @@ -43,6 +59,8 @@ type Series = { export function EventTypesChart() { const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { bucket } = useStore(); const { data, isLoading } = useGetSiteEventCount(); const { width } = useWindowSize(); @@ -50,18 +68,6 @@ export function EventTypesChart() { const timezone = getTimezone(); const [hiddenTypes, setHiddenTypes] = useState>(new Set()); - const translatedLabels: Record = { - Pageviews: t("Pageviews"), - "Custom Events": t("Custom Events"), - Performance: t("Performance"), - Outbound: t("Outbound"), - Errors: t("Errors"), - "Button Clicks": t("Button Clicks"), - Copy: t("Copy"), - "Form Submits": t("Form Submits"), - "Input Changes": t("Input Changes"), - }; - const toggleTypeVisibility = (typeLabel: string) => { setHiddenTypes((prev) => { const next = new Set(prev); @@ -74,6 +80,8 @@ export function EventTypesChart() { }); }; + const translatedLabels = getTranslatedEventTypeLabels(t, isApp); + const { series, legendItems, maxValue, totalPoints } = useMemo(() => { if (!data || data.length === 0) { return { series: [] as Series[], legendItems: [], maxValue: 1, totalPoints: 0 }; diff --git a/client/src/app/[site]/events/page.tsx b/client/src/app/[site]/events/page.tsx index 069c42e04..c57b3fa0b 100644 --- a/client/src/app/[site]/events/page.tsx +++ b/client/src/app/[site]/events/page.tsx @@ -1,8 +1,9 @@ "use client"; import { useExtracted } from "next-intl"; -import { EVENT_FILTERS } from "@/lib/filterGroups"; +import { getEventFilters } from "@/lib/filterGroups"; import { useGetEventNames } from "../../../api/analytics/hooks/events/useGetEventNames"; +import { useGetSite } from "../../../api/admin/hooks/useSites"; import { DisabledOverlay } from "../../../components/DisabledOverlay"; import { useSetPageTitle } from "../../../hooks/useSetPageTitle"; import { SubHeader } from "../components/SubHeader/SubHeader"; @@ -13,13 +14,15 @@ import { EventsChart } from "./components/EventsChart"; export default function EventsPage() { const t = useExtracted(); useSetPageTitle("Events"); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { data: eventNamesData, isLoading: isLoadingEventNames } = useGetEventNames(); return (
- + {/* diff --git a/client/src/components/EventTypeFilter.tsx b/client/src/components/EventTypeFilter.tsx index d9e341313..f1124121e 100644 --- a/client/src/components/EventTypeFilter.tsx +++ b/client/src/components/EventTypeFilter.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; import { EVENT_TYPE_CONFIG, EventType } from "@/lib/events"; import { EventTypeIcon } from "./EventIcons"; import { ToggleChip } from "./ToggleChip"; +import { useGetSite } from "../api/admin/hooks/useSites"; interface EventTypeFilterProps { visibleTypes: Set; @@ -19,9 +20,11 @@ export function EventTypeFilter({ events, }: EventTypeFilterProps) { const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const translatedLabels: Record = { - Pageview: t("Pageview"), + Pageview: isApp ? t("Screenview") : t("Pageview"), Event: t("Event"), Outbound: t("Outbound"), "Button Click": t("Button Click"), From 4ac1a85deaf87f5021ec8a8e8d8c45ee28152ba7 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:33:42 +0100 Subject: [PATCH 09/18] feat: adapt sessions page and session details for app sites --- client/src/app/[site]/sessions/page.tsx | 9 +- .../src/components/Sessions/SessionCard.tsx | 127 +++++++++++++----- .../SessionDetails/SessionInfoTab.tsx | 114 ++++++++++------ 3 files changed, 171 insertions(+), 79 deletions(-) diff --git a/client/src/app/[site]/sessions/page.tsx b/client/src/app/[site]/sessions/page.tsx index 3ef590d57..dac9b6be0 100644 --- a/client/src/app/[site]/sessions/page.tsx +++ b/client/src/app/[site]/sessions/page.tsx @@ -12,14 +12,17 @@ import { Label } from "../../../components/ui/label"; import { Switch } from "../../../components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { useSetPageTitle } from "../../../hooks/useSetPageTitle"; -import { SESSION_PAGE_FILTERS } from "../../../lib/filterGroups"; +import { getSessionPageFilters } from "../../../lib/filterGroups"; import { SubHeader } from "../components/SubHeader/SubHeader"; +import { useGetSite } from "../../../api/admin/hooks/useSites"; const LIMIT = 100; export default function SessionsPage() { const t = useExtracted(); useSetPageTitle("Sessions"); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const [page, setPage] = useState(1); const [identifiedOnly, setIdentifiedOnly] = useState(false); const [minPageviews, setMinPageviews] = useState(undefined); @@ -91,7 +94,7 @@ export default function SessionsPage() { htmlFor="min-pageviews" className="text-sm text-neutral-600 dark:text-neutral-400 whitespace-nowrap" > - {t("Min pageviews")} + {isApp ? t("Min screenviews") : t("Min pageviews")}
- + handleFilterClick(e, "country", session.country)} /> )} - handleFilterClick(e, "browser", session.browser)} - /> + {!isApp && ( + handleFilterClick(e, "browser", session.browser)} + /> + )} handleFilterClick(e, "operating_system", session.operating_system)} /> - handleFilterClick(e, "device_type", session.device_type)} - /> - {session.has_replay === 1 && ( + {!isApp && ( + handleFilterClick(e, "device_type", session.device_type)} + /> + )} + {!isApp && session.has_replay === 1 && ( {formatter(session.pageviews)} - {t("Pageviews")} + {isApp ? t("Screenviews") : t("Pageviews")} @@ -164,11 +171,32 @@ export function SessionCard({ session, onClick, userId, expandedByDefault, highl {t("Events")} - handleFilterClick(e, "channel", session.channel)} - /> + {isApp ? ( + <> + {session.device_model && ( + handleFilterClick(e, "device_model", session.device_model)} + > + + {session.device_model} + + )} + {session.app_version && ( + handleFilterClick(e, "app_version", session.app_version)} + > + v{session.app_version} + + )} + + ) : ( + handleFilterClick(e, "channel", session.channel)} + /> + )}
@@ -204,23 +232,27 @@ export function SessionCard({ session, onClick, userId, expandedByDefault, highl onClick={e => handleFilterClick(e, "country", session.country)} /> )} - handleFilterClick(e, "browser", session.browser)} - /> + {!isApp && ( + handleFilterClick(e, "browser", session.browser)} + /> + )} handleFilterClick(e, "operating_system", session.operating_system)} /> - handleFilterClick(e, "device_type", session.device_type)} - /> - {session.has_replay === 1 && ( + {!isApp && ( + handleFilterClick(e, "device_type", session.device_type)} + /> + )} + {!isApp && session.has_replay === 1 && ( {formatter(session.pageviews)} - {t("Pageviews")} + {isApp ? t("Screenviews") : t("Pageviews")} @@ -254,11 +286,32 @@ export function SessionCard({ session, onClick, userId, expandedByDefault, highl {t("Events")} - handleFilterClick(e, "channel", session.channel)} - /> + {isApp ? ( + <> + {session.device_model && ( + handleFilterClick(e, "device_model", session.device_model)} + > + + {session.device_model} + + )} + {session.app_version && ( + handleFilterClick(e, "app_version", session.app_version)} + > + v{session.app_version} + + )} + + ) : ( + handleFilterClick(e, "channel", session.channel)} + /> + )}
{/* Pages section with tooltips for long paths */} diff --git a/client/src/components/Sessions/SessionDetails/SessionInfoTab.tsx b/client/src/components/Sessions/SessionDetails/SessionInfoTab.tsx index 7dd10657d..d92f58f04 100644 --- a/client/src/components/Sessions/SessionDetails/SessionInfoTab.tsx +++ b/client/src/components/Sessions/SessionDetails/SessionInfoTab.tsx @@ -10,6 +10,7 @@ import { getCountryName, getLanguageName } from "../../../lib/utils"; import { Avatar, generateName } from "../../Avatar"; import { IdentifiedBadge } from "../../IdentifiedBadge"; import { DeviceIcon } from "../../../app/[site]/components/shared/icons/Device"; +import { useGetSite } from "../../../api/admin/hooks/useSites"; interface SessionInfoTabProps { session: GetSessionsResponse[number]; @@ -20,6 +21,8 @@ interface SessionInfoTabProps { region?: string; city?: string; device_type?: string; + device_model?: string; + app_version?: string; browser?: string; browser_version?: string; operating_system?: string; @@ -39,6 +42,8 @@ export function SessionInfoTab({ }: SessionInfoTabProps) { const { getRegionName } = useGetRegionName(); const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const isIdentified = !!session.identified_user_id; return ( @@ -151,34 +156,61 @@ export function SessionInfoTab({ {t("Device Information")}
-
- - {t("Device:")} - -
- - {sessionDetails?.device_type || t("Unknown")} + {!isApp && ( +
+ + {t("Device:")} + +
+ + {sessionDetails?.device_type || t("Unknown")} +
-
+ )} -
- - {t("Browser:")} - -
- - - {sessionDetails?.browser || t("Unknown")} - {sessionDetails?.browser_version && ( - - v{sessionDetails.browser_version} + {isApp ? ( + <> + {sessionDetails?.device_model && ( +
+ + {t("Device Model:")} - )} + + {sessionDetails.device_model} + +
+ )} + {sessionDetails?.app_version && ( +
+ + {t("App Version:")} + + + v{sessionDetails.app_version} + +
+ )} + + ) : ( +
+ + {t("Browser:")} +
+ + + {sessionDetails?.browser || t("Unknown")} + {sessionDetails?.browser_version && ( + + v{sessionDetails.browser_version} + + )} + +
-
+ )}
@@ -227,30 +259,34 @@ export function SessionInfoTab({ {/* Source Information */}

- {t("Source Information")} + {isApp ? t("Session") : t("Source Information")}

-
- - {t("Channel:")} - -
- {sessionDetails?.channel || t("None")} + {!isApp && ( +
+ + {t("Channel:")} + +
+ {sessionDetails?.channel || t("None")} +
-
+ )} -
- - {t("Referrer:")} - -
- {sessionDetails?.referrer || t("None")} + {!isApp && ( +
+ + {t("Referrer:")} + +
+ {sessionDetails?.referrer || t("None")} +
-
+ )}
- {t("Entry Page:")} + {isApp ? t("Entry Screen:") : t("Entry Page:")}
{sessionDetails?.entry_page || t("None")} From d7c4f0e23e129a639e54a71cf5db6f17635a60b2 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:33:46 +0100 Subject: [PATCH 10/18] feat: adapt users pages and user sidebar for app sites --- .../user/[userId]/components/UserSidebar.tsx | 44 ++++--- .../[site]/users/components/UsersTable.tsx | 108 +++++++++++------- client/src/app/[site]/users/page.tsx | 7 +- 3 files changed, 103 insertions(+), 56 deletions(-) diff --git a/client/src/app/[site]/user/[userId]/components/UserSidebar.tsx b/client/src/app/[site]/user/[userId]/components/UserSidebar.tsx index 2a3f83b47..785dbab14 100644 --- a/client/src/app/[site]/user/[userId]/components/UserSidebar.tsx +++ b/client/src/app/[site]/user/[userId]/components/UserSidebar.tsx @@ -20,6 +20,7 @@ import { UserInfo, UserSessionCountResponse } from "../../../../../api/analytics import { DeviceIcon } from "../../../components/shared/icons/Device"; import { useConfigs } from "../../../../../lib/configs"; import { UserLocationMap } from "./UserLocationMap"; +import { useGetSite } from "../../../../../api/admin/hooks/useSites"; interface UserSidebarProps { data: UserInfo | undefined; @@ -88,6 +89,8 @@ function StatCard({ export function UserSidebar({ data, isLoading, sessionCount, getRegionName }: UserSidebarProps) { const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { formatRelative } = useDateTimeFormat(); const { configs } = useConfigs(); const isIdentified = !!data?.identified_user_id; @@ -110,7 +113,7 @@ export function UserSidebar({ data, isLoading, sessionCount, getRegionName }: Us /> } - label={t("Pageviews")} + label={isApp ? t("Screenviews") : t("Pageviews")} value={data?.pageviews ?? "—"} isLoading={isLoading} /> @@ -221,18 +224,33 @@ export function UserSidebar({ data, isLoading, sessionCount, getRegionName }: Us } /> - - } - label={t("Device")} - value={data?.device_type ?? "—"} - /> - } - label={t("Browser")} - value={data?.browser ? `${data.browser}${data.browser_version ? ` v${data.browser_version}` : ""}` : "—"} - /> + {!isApp && ( + + } + label={t("Device")} + value={data?.device_type ?? "—"} + /> + )} + {isApp ? ( + <> + + + + ) : ( + } + label={t("Browser")} + value={data?.browser ? `${data.browser}${data.browser_version ? ` v${data.browser_version}` : ""}` : "—"} + /> + )} } label={t("OS")} diff --git a/client/src/app/[site]/users/components/UsersTable.tsx b/client/src/app/[site]/users/components/UsersTable.tsx index f362fc0ca..2afe979ce 100644 --- a/client/src/app/[site]/users/components/UsersTable.tsx +++ b/client/src/app/[site]/users/components/UsersTable.tsx @@ -38,6 +38,7 @@ import { Browser } from "../../components/shared/icons/Browser"; import { CountryFlag } from "../../components/shared/icons/CountryFlag"; import { OperatingSystem } from "../../components/shared/icons/OperatingSystem"; import { DeviceIcon } from "../../components/shared/icons/Device"; +import { useGetSite } from "../../../../api/admin/hooks/useSites"; const columnHelper = createColumnHelper(); @@ -67,6 +68,8 @@ const SortHeader = ({ column, children }: any) => { export function UsersTable() { const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { formatRelative, formatDateTime } = useDateTimeFormat(); const formatRelativeTime = (dateStr: string) => { @@ -155,52 +158,69 @@ export function UsersTable() { ); }, }), - columnHelper.accessor("referrer", { - header: t("Channel"), - cell: info => { - const channel = info.row.original.channel; - const referrer = info.getValue(); - const domain = extractDomain(referrer); + ...(isApp ? [ + columnHelper.accessor("device_model", { + header: t("Device Model"), + cell: info => { + const model = info.getValue(); + return ( +
handleFilterClick(e, "device_model", model)} + > + {model || t("Unknown")} +
+ ); + }, + }), + ] : [ + columnHelper.accessor("referrer", { + header: t("Channel"), + cell: info => { + const channel = info.row.original.channel; + const referrer = info.getValue(); + const domain = extractDomain(referrer); + + if (domain) { + const displayName = getDisplayName(domain); + return ( +
handleFilterClick(e, "channel", channel)} + > + + {displayName} +
+ ); + } - if (domain) { - const displayName = getDisplayName(domain); return (
handleFilterClick(e, "channel", channel)} > - - {displayName} + + {channel}
); - } - - return ( -
handleFilterClick(e, "channel", channel)} - > - - {channel} -
- ); - }, - }), - columnHelper.accessor("browser", { - header: t("Browser"), - cell: info => { - const browser = info.getValue(); - return ( -
handleFilterClick(e, "browser", browser)} - > - - {browser || t("Unknown")} -
- ); - }, - }), + }, + }), + columnHelper.accessor("browser", { + header: t("Browser"), + cell: info => { + const browser = info.getValue(); + return ( +
handleFilterClick(e, "browser", browser)} + > + + {browser || t("Unknown")} +
+ ); + }, + }), + ]), columnHelper.accessor("operating_system", { header: t("OS"), cell: info => { @@ -216,7 +236,13 @@ export function UsersTable() { ); }, }), - columnHelper.accessor("device_type", { + ...(isApp ? [ + columnHelper.accessor("app_version", { + header: t("App Version"), + cell: info =>
{info.getValue() || "—"}
, + }), + ] : []), + ...(!isApp ? [columnHelper.accessor("device_type", { header: t("Device"), cell: info => { const deviceType = info.getValue(); @@ -230,9 +256,9 @@ export function UsersTable() {
); }, - }), + })] : []), columnHelper.accessor("pageviews", { - header: ({ column }) => {t("Pageviews")}, + header: ({ column }) => {isApp ? t("Screenviews") : t("Pageviews")}, cell: info =>
{info.getValue().toLocaleString()}
, }), columnHelper.accessor("events", { diff --git a/client/src/app/[site]/users/page.tsx b/client/src/app/[site]/users/page.tsx index 04fb6ef1f..1c17de1b5 100644 --- a/client/src/app/[site]/users/page.tsx +++ b/client/src/app/[site]/users/page.tsx @@ -4,7 +4,8 @@ import { useExtracted } from "next-intl"; import { DisabledOverlay } from "../../../components/DisabledOverlay"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../components/ui/basic-tabs"; import { useSetPageTitle } from "../../../hooks/useSetPageTitle"; -import { USER_PAGE_FILTERS } from "../../../lib/filterGroups"; +import { getUserPageFilters } from "../../../lib/filterGroups"; +import { useGetSite } from "../../../api/admin/hooks/useSites"; import { SubHeader } from "../components/SubHeader/SubHeader"; import { TraitsExplorer } from "./components/TraitsExplorer"; import { UsersTable } from "./components/UsersTable"; @@ -12,11 +13,13 @@ import { UsersTable } from "./components/UsersTable"; export default function UsersPage() { useSetPageTitle("Users"); const t = useExtracted(); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; return (
- + {t("Users")} From e920af7961d6f73650db89dfb26689b814f40013 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:33:51 +0100 Subject: [PATCH 11/18] feat: wire app-aware filters to funnels, goals, and journeys pages --- client/src/app/[site]/funnels/page.tsx | 7 +++++-- client/src/app/[site]/goals/page.tsx | 7 +++++-- client/src/app/[site]/journeys/page.tsx | 5 +++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/client/src/app/[site]/funnels/page.tsx b/client/src/app/[site]/funnels/page.tsx index a1f6fdfd5..65c94211a 100644 --- a/client/src/app/[site]/funnels/page.tsx +++ b/client/src/app/[site]/funnels/page.tsx @@ -2,7 +2,8 @@ import { useExtracted } from "next-intl"; import { Input } from "@/components/ui/input"; -import { GOALS_PAGE_FILTERS } from "@/lib/filterGroups"; +import { getFunnelPageFilters } from "@/lib/filterGroups"; +import { useGetSite } from "../../../api/admin/hooks/useSites"; import { useStore } from "@/lib/store"; import { ArrowRight, Funnel } from "lucide-react"; import { Card } from "@/components/ui/card"; @@ -62,6 +63,8 @@ const FunnelRowSkeleton = () => ( export default function FunnelsPage() { const t = useExtracted(); useSetPageTitle("Funnels"); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { site } = useStore(); const { data: funnels, isLoading, error } = useGetFunnels(site); @@ -87,7 +90,7 @@ export default function FunnelsPage() { return (
- +
( export default function GoalsPage() { const t = useExtracted(); useSetPageTitle("Goals"); + const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { site } = useStore(); const [searchQuery, setSearchQuery] = useState(""); @@ -142,7 +145,7 @@ export default function GoalsPage() { return (
- +
>({}); const { data: siteMetadata } = useGetSite(); + const isApp = siteMetadata?.type === "app"; const { time } = useStore(); // Fetch path suggestions @@ -53,7 +54,7 @@ export default function JourneysPage() { return (
- +
{t("{steps} steps", { steps: String(steps) })} From 4cd17d9a7ca80f5218ffaaacfe3f32eb7726a92e Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:21:01 +0100 Subject: [PATCH 12/18] feat: SDK integration instructions and error tracking for app sites --- .../errors/components/EnableErrorTracking.tsx | 4 +- .../SiteSettings/SDKIntegration.tsx | 39 ++++ .../SiteSettings/SiteConfiguration.tsx | 197 +++++++++++------- .../components/SiteSettings/SiteSettings.tsx | 15 +- 4 files changed, 171 insertions(+), 84 deletions(-) create mode 100644 client/src/components/SiteSettings/SDKIntegration.tsx diff --git a/client/src/app/[site]/errors/components/EnableErrorTracking.tsx b/client/src/app/[site]/errors/components/EnableErrorTracking.tsx index 9b0733188..30bef40f5 100644 --- a/client/src/app/[site]/errors/components/EnableErrorTracking.tsx +++ b/client/src/app/[site]/errors/components/EnableErrorTracking.tsx @@ -27,7 +27,9 @@ export function EnableErrorTracking() {
- {t("Error tracking captures JavaScript errors and exceptions from your application.")} {t("Note:")} {t("Enabling error tracking will increase your event usage.")} + {siteMetadata?.type && siteMetadata.type !== "web" + ? t("Error tracking captures errors and exceptions from your application.") + : t("Error tracking captures JavaScript errors and exceptions from your application.")} {t("Note:")} {t("Enabling error tracking will increase your event usage.")}
+ ); +} diff --git a/client/src/components/SiteSettings/SiteConfiguration.tsx b/client/src/components/SiteSettings/SiteConfiguration.tsx index d2505643f..5d1be4c33 100644 --- a/client/src/components/SiteSettings/SiteConfiguration.tsx +++ b/client/src/components/SiteSettings/SiteConfiguration.tsx @@ -24,7 +24,7 @@ import { Switch } from "@/components/ui/switch"; import { deleteSite, updateSiteConfig, SiteResponse } from "@/api/admin/endpoints"; import { useGetSitesFromOrg } from "@/api/admin/hooks/useSites"; -import { normalizeDomain } from "@/lib/utils"; +import { normalizeDomain, isValidDomain, isValidPackageName } from "@/lib/utils"; import { IPExclusionManager } from "./IPExclusionManager"; import { CountryExclusionManager } from "./CountryExclusionManager"; import { GSCManager } from "./GSCManager"; @@ -55,6 +55,8 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S const { refetch } = useGetSitesFromOrg(siteMetadata?.organizationId ?? ""); const router = useRouter(); + const siteType = siteMetadata.type || "web"; + const [newName, setNewName] = useState(siteMetadata.name); const [isChangingName, setIsChangingName] = useState(false); const [newDomain, setNewDomain] = useState(siteMetadata.domain); @@ -136,9 +138,21 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S return; } + if (siteType === "web") { + if (!isValidDomain(newDomain)) { + toast.error(t("Invalid domain format")); + return; + } + } else { + if (!isValidPackageName(newDomain)) { + toast.error(t("Invalid package name format")); + return; + } + } + try { setIsChangingDomain(true); - const normalizedDomain = normalizeDomain(newDomain); + const normalizedDomain = siteType === "web" ? normalizeDomain(newDomain) : newDomain; await updateSiteConfig(siteMetadata.siteId, { domain: normalizedDomain }); toast.success(t("Domain updated successfully")); router.refresh(); @@ -212,9 +226,11 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S const sessionReplayDisabled = (!subscription?.planName.includes("pro") || (!!subscription?.isTrial && (subscription?.eventLimit ?? 0) >= 500_000)) && IS_CLOUD; const standardFeaturesDisabled = !subscription?.planName.includes("standard") && !subscription?.planName.includes("pro") && !subscription?.planName.includes("appsumo") && IS_CLOUD; + const isWeb = siteType === "web"; + // Configuration for analytics feature toggles const analyticsToggles: ToggleConfig[] = [ - ...(!subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading + ...(!subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading && isWeb ? [ { id: "sessionReplay", @@ -229,7 +245,7 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S } as ToggleConfig, ] : []), - ...(IS_CLOUD + ...(IS_CLOUD && isWeb ? [ { id: "webVitals", @@ -244,24 +260,28 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S } as ToggleConfig, ] : []), - { - id: "trackSpaNavigation", - label: t("SPA Navigation"), - description: t("Automatically track navigation in single-page applications"), - value: toggleStates.trackSpaNavigation, - key: "trackSpaNavigation", - enabledMessage: t("SPA navigation tracking enabled"), - disabledMessage: t("SPA navigation tracking disabled"), - }, - { - id: "trackUrlParams", - label: t("URL Parameters"), - description: t("Include query string parameters in page tracking"), - value: toggleStates.trackUrlParams, - key: "trackUrlParams", - enabledMessage: t("URL parameters tracking enabled"), - disabledMessage: t("URL parameters tracking disabled"), - }, + ...(isWeb + ? [ + { + id: "trackSpaNavigation", + label: t("SPA Navigation"), + description: t("Automatically track navigation in single-page applications"), + value: toggleStates.trackSpaNavigation, + key: "trackSpaNavigation", + enabledMessage: t("SPA navigation tracking enabled"), + disabledMessage: t("SPA navigation tracking disabled"), + } as ToggleConfig, + { + id: "trackUrlParams", + label: t("URL Parameters"), + description: t("Include query string parameters in page tracking"), + value: toggleStates.trackUrlParams, + key: "trackUrlParams", + enabledMessage: t("URL parameters tracking enabled"), + disabledMessage: t("URL parameters tracking disabled"), + } as ToggleConfig, + ] + : []), { id: "trackInitialPageView", label: t("Initial Page View"), @@ -273,61 +293,70 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S }, ]; - const autoCaptureToggles: ToggleConfig[] = [ - { - id: "trackOutbound", - label: t("Outbound Links"), - description: t("Track when users click on external links"), - value: toggleStates.trackOutbound, - key: "trackOutbound", - enabledMessage: t("Outbound tracking enabled"), - disabledMessage: t("Outbound tracking disabled"), - }, - { - id: "trackErrors", - label: t("Error Tracking"), - description: t("Capture JavaScript errors and exceptions from your site"), - value: toggleStates.trackErrors, - key: "trackErrors", - enabledMessage: t("Error tracking enabled"), - disabledMessage: t("Error tracking disabled"), - disabled: standardFeaturesDisabled, - badge: Standard, - }, - { - id: "trackButtonClicks", - label: t("Button Clicks"), - description: t("Automatically track clicks on all buttons"), - value: toggleStates.trackButtonClicks, - key: "trackButtonClicks", - enabledMessage: t("Button click tracking enabled"), - disabledMessage: t("Button click tracking disabled"), - disabled: standardFeaturesDisabled, - badge: Standard, - }, - { - id: "trackCopy", - label: t("Copy Events"), - description: t("Track when users copy text from your site"), - value: toggleStates.trackCopy, - key: "trackCopy", - enabledMessage: t("Copy tracking enabled"), - disabledMessage: t("Copy tracking disabled"), - disabled: standardFeaturesDisabled, - badge: Standard, - }, - { - id: "trackFormInteractions", - label: t("Form Interactions"), - description: t("Automatically track form submissions and input/select changes"), - value: toggleStates.trackFormInteractions, - key: "trackFormInteractions", - enabledMessage: t("Form interaction tracking enabled"), - disabledMessage: t("Form interaction tracking disabled"), - disabled: standardFeaturesDisabled, - badge: Standard, - }, - ]; + // Error tracking - available for all site types + const errorTrackingToggle: ToggleConfig = { + id: "trackErrors", + label: t("Error Tracking"), + description: isWeb + ? t("Capture JavaScript errors and exceptions from your site") + : t("Capture errors and exceptions from your app"), + value: toggleStates.trackErrors, + key: "trackErrors", + enabledMessage: t("Error tracking enabled"), + disabledMessage: t("Error tracking disabled"), + disabled: standardFeaturesDisabled, + badge: Standard, + }; + + // Auto capture toggles - DOM-based features only available for web + const webOnlyToggles: ToggleConfig[] = isWeb + ? [ + { + id: "trackOutbound", + label: t("Outbound Links"), + description: t("Track when users click on external links"), + value: toggleStates.trackOutbound, + key: "trackOutbound", + enabledMessage: t("Outbound tracking enabled"), + disabledMessage: t("Outbound tracking disabled"), + }, + { + id: "trackButtonClicks", + label: t("Button Clicks"), + description: t("Automatically track clicks on all buttons"), + value: toggleStates.trackButtonClicks, + key: "trackButtonClicks", + enabledMessage: t("Button click tracking enabled"), + disabledMessage: t("Button click tracking disabled"), + disabled: standardFeaturesDisabled, + badge: Standard, + }, + { + id: "trackCopy", + label: t("Copy Events"), + description: t("Track when users copy text from your site"), + value: toggleStates.trackCopy, + key: "trackCopy", + enabledMessage: t("Copy tracking enabled"), + disabledMessage: t("Copy tracking disabled"), + disabled: standardFeaturesDisabled, + badge: Standard, + }, + { + id: "trackFormInteractions", + label: t("Form Interactions"), + description: t("Automatically track form submissions and input/select changes"), + value: toggleStates.trackFormInteractions, + key: "trackFormInteractions", + enabledMessage: t("Form interaction tracking enabled"), + disabledMessage: t("Form interaction tracking disabled"), + disabled: standardFeaturesDisabled, + badge: Standard, + }, + ] + : []; + + const autoCaptureToggles: ToggleConfig[] = [errorTrackingToggle, ...webOnlyToggles]; const renderToggleSection = (toggles: ToggleConfig[], title?: string) => ( <> @@ -363,7 +392,9 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S
{renderToggleSection(privacyToggles, t("Privacy & Security"))}
{renderToggleSection(analyticsToggles, t("Analytics Features"))}
-
{renderToggleSection(autoCaptureToggles, t("Auto Capture"))}
+ {autoCaptureToggles.length > 0 && ( +
{renderToggleSection(autoCaptureToggles, t("Auto Capture"))}
+ )} {IS_CLOUD && } @@ -390,14 +421,18 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S
-

{t("Domain")}

-

{t("The domain used for tracking")}

+

+ {siteType === "web" ? t("Change Domain") : t("Change Package Name")} +

+

+ {siteType === "web" ? t("Update the domain for this site") : t("Update the package name for this site")} +

setNewDomain(e.target.value.toLowerCase())} - placeholder="example.com" + placeholder={siteType === "web" ? "example.com" : "com.example.app"} />
{isApp ? ( <> @@ -123,7 +123,7 @@ export function NoData() {
{t("Place this snippet in the {headTag} of your website:", { headTag: "" })}
`} + code={``} className="text-xs" /> diff --git a/client/src/app/components/AddSite.tsx b/client/src/app/components/AddSite.tsx index 911ea1020..a9e74ccda 100644 --- a/client/src/app/components/AddSite.tsx +++ b/client/src/app/components/AddSite.tsx @@ -78,6 +78,14 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa type: siteType, }); + if (iconBase64) { + try { + await uploadSiteIcon(site.siteId, iconBase64); + } catch (iconError) { + console.warn("Site created but icon upload failed", iconError); + } + } + resetStore(); setSite(site.siteId.toString()); router.push(`/${site.siteId}`); diff --git a/client/src/components/SiteSettings/SiteConfiguration.tsx b/client/src/components/SiteSettings/SiteConfiguration.tsx index 5d1be4c33..c37322640 100644 --- a/client/src/components/SiteSettings/SiteConfiguration.tsx +++ b/client/src/components/SiteSettings/SiteConfiguration.tsx @@ -398,6 +398,92 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S {IS_CLOUD && } + {siteType !== "web" && ( +
+
+

{t("App Icon")}

+

+ {t("Displayed as a favicon in the sidebar, site selector, and site cards. Accepts any image format — automatically resized to 128×128 PNG. Max 50 KB.")} +

+
+
+
+ +
+ {t("App { + (e.target as HTMLImageElement).style.display = "none"; + (e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden"); + }} + onLoad={(e) => { + (e.target as HTMLImageElement).style.display = ""; + (e.target as HTMLImageElement).nextElementSibling?.classList.add("hidden"); + }} + /> +
+ +
+
+
+
+ + +
+
+
+ )}

{t("Site Name")}

diff --git a/server/src/api/sites/siteIcon.ts b/server/src/api/sites/siteIcon.ts new file mode 100644 index 000000000..53dc496e5 --- /dev/null +++ b/server/src/api/sites/siteIcon.ts @@ -0,0 +1,113 @@ +import { eq } from "drizzle-orm"; +import { FastifyReply, FastifyRequest } from "fastify"; +import { db } from "../../db/postgres/postgres.js"; +import { sites } from "../../db/postgres/schema.js"; + +interface SiteIdParams { + Params: { + siteId: string; + }; +} + +const MAX_ICON_SIZE = 50 * 1024; // 50KB + +export async function getSiteIcon( + request: FastifyRequest, + reply: FastifyReply +) { + const parsedSiteId = Number.parseInt(request.params.siteId, 10); + if (!Number.isInteger(parsedSiteId) || parsedSiteId <= 0) { + return reply.status(400).send({ error: "Invalid site ID" }); + } + + try { + const site = await db.query.sites.findFirst({ + where: eq(sites.siteId, parsedSiteId), + columns: { icon: true }, + }); + + if (!site?.icon) { + return reply.status(404).send({ error: "No icon found" }); + } + + return reply + .header("Content-Type", "image/png") + .header("Cache-Control", "public, max-age=86400") + .send(Buffer.from(site.icon)); + } catch (error) { + console.error("Error retrieving site icon:", error); + return reply.status(500).send({ error: "Internal server error" }); + } +} + +export async function uploadSiteIcon( + request: FastifyRequest, + reply: FastifyReply +) { + const parsedSiteId = Number.parseInt(request.params.siteId, 10); + if (!Number.isInteger(parsedSiteId) || parsedSiteId <= 0) { + return reply.status(400).send({ error: "Invalid site ID" }); + } + + const { icon } = request.body as { icon: string }; + + if (!icon) { + return reply.status(400).send({ error: "icon field is required" }); + } + + try { + const buffer = Buffer.from(icon, "base64"); + + if (buffer.length > MAX_ICON_SIZE) { + return reply.status(400).send({ error: "Icon exceeds 50KB limit" }); + } + + // Validate PNG magic bytes + const pngMagic = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + if (buffer.length < 4 || !buffer.subarray(0, 4).equals(pngMagic)) { + return reply.status(400).send({ error: "Icon must be a PNG image" }); + } + + const result = await db + .update(sites) + .set({ icon: buffer }) + .where(eq(sites.siteId, parsedSiteId)) + .returning({ siteId: sites.siteId }); + + if (result.length === 0) { + return reply.status(404).send({ error: "Site not found" }); + } + + return reply.status(200).send({ success: true }); + } catch (error) { + console.error("Error uploading site icon:", error); + return reply.status(500).send({ error: "Internal server error" }); + } +} + +export async function deleteSiteIcon( + request: FastifyRequest, + reply: FastifyReply +) { + const parsedSiteId = Number.parseInt(request.params.siteId, 10); + if (!Number.isInteger(parsedSiteId) || parsedSiteId <= 0) { + return reply.status(400).send({ error: "Invalid site ID" }); + } + + try { + const result = await db + .update(sites) + .set({ icon: null }) + .where(eq(sites.siteId, parsedSiteId)) + .returning({ siteId: sites.siteId }); + + if (result.length === 0) { + return reply.status(404).send({ error: "Site not found" }); + } + + return reply.status(200).send({ success: true }); + } catch (error) { + console.error("Error deleting site icon:", error); + return reply.status(500).send({ error: "Internal server error" }); + } +} From 94b7f0e28a7eed69b1d907795ebe987d167056c6 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 09:53:06 +0100 Subject: [PATCH 14/18] fix: hide block bot traffic toggle for app sites --- .../SiteSettings/SiteConfiguration.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/client/src/components/SiteSettings/SiteConfiguration.tsx b/client/src/components/SiteSettings/SiteConfiguration.tsx index c37322640..5276fd4f0 100644 --- a/client/src/components/SiteSettings/SiteConfiguration.tsx +++ b/client/src/components/SiteSettings/SiteConfiguration.tsx @@ -201,15 +201,19 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S enabledMessage: t("User ID salting enabled"), disabledMessage: t("User ID salting disabled"), }, - { - id: "blockBots", - label: t("Block Bot Traffic"), - description: t("Traffic from known bots and crawlers will not be tracked"), - value: toggleStates.blockBots, - key: "blockBots", - enabledMessage: t("Bot blocking enabled"), - disabledMessage: t("Bot blocking disabled"), - }, + ...(siteType === "web" + ? [ + { + id: "blockBots", + label: t("Block Bot Traffic"), + description: t("Traffic from known bots and crawlers will not be tracked"), + value: toggleStates.blockBots, + key: "blockBots", + enabledMessage: t("Bot blocking enabled"), + disabledMessage: t("Bot blocking disabled"), + } as ToggleConfig, + ] + : []), { id: "trackIp", label: t("Track IP Address"), From 62bfbf34accf3f96d083123afc82be68055dee11 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 15:38:34 +0100 Subject: [PATCH 15/18] feat: add app icon upload with bytea storage and canvas resize --- client/src/api/admin/endpoints/index.ts | 2 + client/src/api/admin/endpoints/sites.ts | 14 +++++++ .../components/Sidebar/SiteSelector.tsx | 4 +- client/src/app/components/AddSite.tsx | 42 ++++++++++++++++++- client/src/components/Favicon.tsx | 19 ++++++++- client/src/components/SiteCard.tsx | 2 +- .../SiteSettings/SiteConfiguration.tsx | 9 +++- client/src/lib/imageUtils.ts | 41 ++++++++++++++++++ server/src/api/sites/index.ts | 3 ++ server/src/api/sites/siteIcon.ts | 4 ++ server/src/db/postgres/schema.ts | 8 ++++ server/src/index.ts | 8 ++++ 12 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 client/src/lib/imageUtils.ts diff --git a/client/src/api/admin/endpoints/index.ts b/client/src/api/admin/endpoints/index.ts index 19ebbf874..87d00fc7f 100644 --- a/client/src/api/admin/endpoints/index.ts +++ b/client/src/api/admin/endpoints/index.ts @@ -2,11 +2,13 @@ export { addSite, deleteSite, + deleteSiteIcon, updateSiteConfig, fetchSite, fetchSitesFromOrg, fetchSiteHasData, fetchSiteIsPublic, + uploadSiteIcon, verifyScript, } from "./sites"; export type { SiteResponse, GetSitesFromOrgResponse, VerifyScriptResponse } from "./sites"; diff --git a/client/src/api/admin/endpoints/sites.ts b/client/src/api/admin/endpoints/sites.ts index abe067e76..1c8afc584 100644 --- a/client/src/api/admin/endpoints/sites.ts +++ b/client/src/api/admin/endpoints/sites.ts @@ -160,3 +160,17 @@ export interface VerifyScriptResponse { export function verifyScript(siteId: number | string) { return authedFetch(`/sites/${siteId}/verify-script`); } + +export function uploadSiteIcon(siteId: number, icon: string) { + return authedFetch<{ success: boolean }>(`/sites/${siteId}/icon`, undefined, { + method: "PUT", + data: { icon }, + headers: { "Content-Type": "application/json" }, + }); +} + +export function deleteSiteIcon(siteId: number) { + return authedFetch<{ success: boolean }>(`/sites/${siteId}/icon`, undefined, { + method: "DELETE", + }); +} diff --git a/client/src/app/[site]/components/Sidebar/SiteSelector.tsx b/client/src/app/[site]/components/Sidebar/SiteSelector.tsx index 758a14433..07bd07444 100644 --- a/client/src/app/[site]/components/Sidebar/SiteSelector.tsx +++ b/client/src/app/[site]/components/Sidebar/SiteSelector.tsx @@ -104,7 +104,7 @@ function SiteSelectorContent({ onSiteSelect }: { onSiteSelect: () => void }) { )} >
- +
{site.name}
{site.type && site.type !== "web" && ( @@ -158,7 +158,7 @@ function SiteSelectorWrapper() { {site ? ( diff --git a/client/src/app/components/AddSite.tsx b/client/src/app/components/AddSite.tsx index a9e74ccda..727eb8da8 100644 --- a/client/src/app/components/AddSite.tsx +++ b/client/src/app/components/AddSite.tsx @@ -5,8 +5,9 @@ import { DateTime } from "luxon"; import { useExtracted } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { addSite } from "../../api/admin/endpoints"; +import { addSite, uploadSiteIcon } from "../../api/admin/endpoints"; import { useGetSitesFromOrg } from "../../api/admin/hooks/useSites"; +import { resizeImageToIcon } from "../../lib/imageUtils"; import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert"; import { Dialog, @@ -48,6 +49,8 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa const [saltUserIds, setSaltUserIds] = useState(false); const [error, setError] = useState(""); const [siteType, setSiteType] = useState<"web" | "app">("web"); + const [iconPreview, setIconPreview] = useState(null); + const [iconBase64, setIconBase64] = useState(null); const handleSubmit = async () => { setError(""); @@ -105,6 +108,20 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa setIsPublic(false); setSaltUserIds(false); setSiteType("web"); + setIconPreview(null); + setIconBase64(null); + }; + + const handleIconSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const base64 = await resizeImageToIcon(file); + setIconBase64(base64); + setIconPreview(`data:image/png;base64,${base64}`); + } catch { + setError(t("Failed to process icon image")); + } }; @@ -217,6 +234,29 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa placeholder={t("Display name (defaults to domain)")} />
+ {siteType === "app" && ( +
+ +
+ {iconPreview ? ( + App icon preview + ) : ( +
+ +
+ )} +
+ +

{t("Optional. Resized to 128x128 PNG.")}

+
+
+
+ )} {/* Public Analytics Setting */}
diff --git a/client/src/components/Favicon.tsx b/client/src/components/Favicon.tsx index 57cfbcdd0..6e11a8672 100644 --- a/client/src/components/Favicon.tsx +++ b/client/src/components/Favicon.tsx @@ -1,21 +1,36 @@ import { Smartphone } from "lucide-react"; import { useState } from "react"; +import { BACKEND_URL } from "../lib/const"; import { cn } from "../lib/utils"; export function Favicon({ domain, className, siteType, + siteId, + iconVersion, }: { domain: string; className?: string; siteType?: "web" | "app"; + siteId?: number; + iconVersion?: number; }) { const [imageError, setImageError] = useState(false); const firstLetter = domain.charAt(0).toUpperCase(); if (siteType && siteType !== "web") { - const Icon = Smartphone; + if (siteId && !imageError) { + return ( + {`Icon setImageError(true)} + /> + ); + } + return (
- +
); } diff --git a/client/src/components/SiteCard.tsx b/client/src/components/SiteCard.tsx index cbad20cd7..018e22e48 100644 --- a/client/src/components/SiteCard.tsx +++ b/client/src/components/SiteCard.tsx @@ -106,7 +106,7 @@ export function SiteCard({ siteId, name, domain, tags = [], allTags = [], onTags ) : ( <>
- + {name} {siteType && siteType !== "web" && ( diff --git a/client/src/components/SiteSettings/SiteConfiguration.tsx b/client/src/components/SiteSettings/SiteConfiguration.tsx index 5276fd4f0..7c40089d9 100644 --- a/client/src/components/SiteSettings/SiteConfiguration.tsx +++ b/client/src/components/SiteSettings/SiteConfiguration.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertTriangle } from "lucide-react"; +import { AlertTriangle, Smartphone, Trash2, Upload } from "lucide-react"; import { useExtracted } from "next-intl"; import { useRouter } from "next/navigation"; import { useState, useCallback, ReactNode } from "react"; @@ -22,8 +22,10 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { deleteSite, updateSiteConfig, SiteResponse } from "@/api/admin/endpoints"; +import { deleteSite, updateSiteConfig, uploadSiteIcon, deleteSiteIcon, SiteResponse } from "@/api/admin/endpoints"; import { useGetSitesFromOrg } from "@/api/admin/hooks/useSites"; +import { resizeImageToIcon } from "@/lib/imageUtils"; +import { BACKEND_URL } from "@/lib/const"; import { normalizeDomain, isValidDomain, isValidPackageName } from "@/lib/utils"; import { IPExclusionManager } from "./IPExclusionManager"; import { CountryExclusionManager } from "./CountryExclusionManager"; @@ -82,6 +84,9 @@ export function SiteConfiguration({ siteMetadata, disabled = false, onClose }: S }); const [loadingStates, setLoadingStates] = useState>({}); + const [iconVersion, setIconVersion] = useState(0); + const [isUploadingIcon, setIsUploadingIcon] = useState(false); + const [isDeletingIcon, setIsDeletingIcon] = useState(false); // Generic toggle handler const handleToggle = useCallback( diff --git a/client/src/lib/imageUtils.ts b/client/src/lib/imageUtils.ts new file mode 100644 index 000000000..b72a9a582 --- /dev/null +++ b/client/src/lib/imageUtils.ts @@ -0,0 +1,41 @@ +/** + * Resize an image file to a square icon using canvas. + * Returns a base64-encoded PNG string (without data URI prefix). + */ +export function resizeImageToIcon( + file: File, + size = 128 +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + URL.revokeObjectURL(url); + + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Could not get canvas context")); + return; + } + + ctx.drawImage(img, 0, 0, size, size); + + const dataUrl = canvas.toDataURL("image/png"); + // Strip the "data:image/png;base64," prefix + const base64 = dataUrl.split(",")[1]; + resolve(base64); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Failed to load image")); + }; + + img.src = url; + }); +} diff --git a/server/src/api/sites/index.ts b/server/src/api/sites/index.ts index cfbd2bff7..dce44eb52 100644 --- a/server/src/api/sites/index.ts +++ b/server/src/api/sites/index.ts @@ -20,3 +20,6 @@ export { createSiteImport } from "./createSiteImport.js"; export { batchImportEvents } from "./batchImportEvents.js"; export { deleteSiteImport } from "./deleteSiteImport.js"; export { verifyScript } from "./verifyScript.js"; + +// Site Icon +export { getSiteIcon, uploadSiteIcon, deleteSiteIcon } from "./siteIcon.js"; diff --git a/server/src/api/sites/siteIcon.ts b/server/src/api/sites/siteIcon.ts index 53dc496e5..3d64e4aa8 100644 --- a/server/src/api/sites/siteIcon.ts +++ b/server/src/api/sites/siteIcon.ts @@ -49,6 +49,10 @@ export async function uploadSiteIcon( return reply.status(400).send({ error: "Invalid site ID" }); } + if (!request.body) { + return reply.status(400).send({ error: "Request body is required" }); + } + const { icon } = request.body as { icon: string }; if (!icon) { diff --git a/server/src/db/postgres/schema.ts b/server/src/db/postgres/schema.ts index 98b81ccd6..23eceed83 100644 --- a/server/src/db/postgres/schema.ts +++ b/server/src/db/postgres/schema.ts @@ -2,6 +2,7 @@ import { sql } from "drizzle-orm"; import { boolean, check, + customType, foreignKey, index, integer, @@ -17,6 +18,12 @@ import { uuid, } from "drizzle-orm/pg-core"; +const bytea = customType<{ data: Buffer }>({ + dataType() { + return "bytea"; + }, +}); + // User table (BetterAuth) export const user = pgTable( "user", @@ -87,6 +94,7 @@ export const sites = pgTable("sites", { apiKey: text("api_key"), // Format: rb_{64_hex_chars} = 67 chars total privateLinkKey: text("private_link_key"), tags: jsonb("tags").default([]).$type(), + icon: bytea("icon"), }); // Active sessions table diff --git a/server/src/index.ts b/server/src/index.ts index 201578195..f1356c3ff 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -79,10 +79,12 @@ import { createSiteImport, deleteSite, deleteSiteImport, + deleteSiteIcon, getSite, getSiteExcludedCountries, getSiteExcludedIPs, getSiteHasData, + getSiteIcon, getSiteImports, getSiteIsPublic, getSitePrivateLinkConfig, @@ -90,6 +92,7 @@ import { getTrackingConfig, updateSiteConfig, updateSitePrivateLinkConfig, + uploadSiteIcon, verifyScript, } from "./api/sites/index.js"; import { @@ -302,6 +305,11 @@ async function sitesRoutes(fastify: FastifyInstance) { fastify.get("/sites/:siteId/excluded-countries", authSite, getSiteExcludedCountries); fastify.get("/sites/:siteId/verify-script", authSite, verifyScript); + // Site Icon (GET is fully public - it's just a favicon) + fastify.get("/sites/:siteId/icon", { preHandler: [resolveSiteId] as any }, getSiteIcon); + fastify.put("/sites/:siteId/icon", adminSite, uploadSiteIcon); + fastify.delete("/sites/:siteId/icon", adminSite, deleteSiteIcon); + // Site Imports fastify.get("/sites/:siteId/imports", adminSite, getSiteImports); fastify.post("/sites/:siteId/imports", adminSite, createSiteImport); From ac84e2f79a08617581e3e1ead6a74c0937171c79 Mon Sep 17 00:00:00 2001 From: LuRy Date: Thu, 26 Feb 2026 17:32:52 +0100 Subject: [PATCH 16/18] fix: update SDK link to renamed rybbit-flutter-sdk repo --- client/src/app/[site]/components/Header/NoData.tsx | 4 ++-- client/src/components/SiteSettings/SDKIntegration.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/app/[site]/components/Header/NoData.tsx b/client/src/app/[site]/components/Header/NoData.tsx index 492212523..da4f848eb 100644 --- a/client/src/app/[site]/components/Header/NoData.tsx +++ b/client/src/app/[site]/components/Header/NoData.tsx @@ -100,7 +100,7 @@ export function NoData() { /> {t("See the")}{" "} - + {t("Flutter SDK documentation")} {" "} {t("for installation and usage instructions.")} @@ -113,7 +113,7 @@ export function NoData() { icon={} title="Flutter" description="" - href="https://github.com/nks-hub/rybbit-flutter" + href="https://github.com/nks-hub/rybbit-flutter-sdk" />
diff --git a/client/src/components/SiteSettings/SDKIntegration.tsx b/client/src/components/SiteSettings/SDKIntegration.tsx index e8d74c6b4..274372377 100644 --- a/client/src/components/SiteSettings/SDKIntegration.tsx +++ b/client/src/components/SiteSettings/SDKIntegration.tsx @@ -26,7 +26,7 @@ export function SDKIntegration({
- e.stopPropagation()} - className="hover:underline" - title={pagePath} - > - {truncateString(pagePath, 60)} - + {isApp ? ( + + {truncateString(pagePath, 60)} + + ) : ( + e.stopPropagation()} + className="hover:underline" + title={pagePath} + > + {truncateString(pagePath, 60)} + + )}
From f607f47eeddd43745e7ecaddf4abb48f905c3987 Mon Sep 17 00:00:00 2001 From: LuRy Date: Fri, 13 Mar 2026 08:06:08 +0100 Subject: [PATCH 18/18] fix: remove redundant v prefix from app_version display --- .../components/Sessions/SessionDetails/SessionInfoTab.tsx | 2 +- server/src/services/tracker/pageviewQueue.ts | 2 +- server/src/utils.ts | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/components/Sessions/SessionDetails/SessionInfoTab.tsx b/client/src/components/Sessions/SessionDetails/SessionInfoTab.tsx index d92f58f04..ef98c5e92 100644 --- a/client/src/components/Sessions/SessionDetails/SessionInfoTab.tsx +++ b/client/src/components/Sessions/SessionDetails/SessionInfoTab.tsx @@ -186,7 +186,7 @@ export function SessionInfoTab({ {t("App Version:")} - v{sessionDetails.app_version} + {sessionDetails.app_version}
)} diff --git a/server/src/services/tracker/pageviewQueue.ts b/server/src/services/tracker/pageviewQueue.ts index ba986de17..66cd09036 100644 --- a/server/src/services/tracker/pageviewQueue.ts +++ b/server/src/services/tracker/pageviewQueue.ts @@ -123,7 +123,7 @@ class PageviewQueue { is_tor: dataForIp?.isTor ?? null, is_satellite: dataForIp?.isSatellite ?? null, app_version: pv.app_version || "", - device_model: pv.device_model || "", + device_model: pv.device_model || sdkUA?.deviceModel || "", }; }); diff --git a/server/src/utils.ts b/server/src/utils.ts index 352bb24ee..8dbc1f7dd 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -131,10 +131,11 @@ export function parseSDKUserAgent(userAgent: string): { browserVersion: string; os: string; osVersion: string; + deviceModel: string; } | null { // 3-part format (with packageName) const match3 = userAgent.match( - /^(.+?)\/(\S+)\s+\([^;]+;\s*(\w+)\s+([^;]+);\s*[^)]+\)\s+\S+\/\S+$/ + /^(.+?)\/(\S+)\s+\([^;]+;\s*(\w+)\s+([^;]+);\s*([^)]+)\)\s+\S+\/\S+$/ ); if (match3) { return { @@ -142,12 +143,13 @@ export function parseSDKUserAgent(userAgent: string): { browserVersion: match3[2], os: match3[3], osVersion: match3[4].trim(), + deviceModel: match3[5].trim(), }; } // 2-part format (without packageName) const match2 = userAgent.match( - /^(.+?)\/(\S+)\s+\((\w+)\s+([^;]+);\s*[^)]+\)\s+\S+\/\S+$/ + /^(.+?)\/(\S+)\s+\((\w+)\s+([^;]+);\s*([^)]+)\)\s+\S+\/\S+$/ ); if (match2) { return { @@ -155,6 +157,7 @@ export function parseSDKUserAgent(userAgent: string): { browserVersion: match2[2], os: match2[3], osVersion: match2[4].trim(), + deviceModel: match2[5].trim(), }; }