feat: add UTM params to external links (#168)

This commit is contained in:
Zhengchao An
2026-07-16 18:08:33 +08:00
committed by GitHub
parent c0bff844b3
commit 6a8d96eab8
9 changed files with 110 additions and 8 deletions
+2 -1
View File
@@ -18,6 +18,7 @@ import { buildRoute, getLoginRoute } from "@/lib/routes"
import { configManager } from "@/lib/config"
import { checkServerHealth } from "@/lib/config-helpers"
import { getThemeManifest } from "@/lib/theme/manifest"
import { withUtm } from "@/lib/utm"
export default function ConfigPage() {
return (
@@ -32,7 +33,7 @@ function ConfigPageContent() {
const router = useRouter()
const message = useMessage()
const theme = getThemeManifest()
const docs = theme.links.documentation ?? "https://docs.rustfs.com/?ref=console"
const docs = withUtm(theme.links.documentation ?? "https://docs.rustfs.com/?ref=console", "config-page")
const [serverHost, setServerHost] = useState(() =>
typeof window !== "undefined" ? (localStorage.getItem("rustfs-server-host") ?? "") : "",
+3 -2
View File
@@ -32,6 +32,7 @@ import { canAccessDashboardRoute } from "@/lib/dashboard-route-meta"
import { SidebarVersion } from "@/components/sidebars/version"
import { useDirection } from "@/components/ui/direction"
import { getThemeManifest } from "@/lib/theme/manifest"
import { withUtm } from "@/lib/utm"
import { ThemeLogo } from "@/components/theme/logo"
const theme = getThemeManifest()
@@ -200,7 +201,7 @@ export function AppSidebar() {
size="sm"
render={
<a
href={normalizedTo(child)}
href={withUtm(normalizedTo(child), "sidebar")}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2"
@@ -238,7 +239,7 @@ export function AppSidebar() {
tooltip={getLabel(item)}
render={
<a
href={normalizedTo(item)}
href={withUtm(normalizedTo(item), "sidebar")}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-3"
+2 -1
View File
@@ -5,6 +5,7 @@ import Link from "next/link"
import { useTranslation } from "react-i18next"
import { ThemeLogo } from "@/components/theme/logo"
import { getThemeManifest } from "@/lib/theme/manifest"
import { withUtm } from "@/lib/utm"
import { buildRoute } from "@/lib/routes"
import { RiArrowRightLongFill } from "@remixicon/react"
import { FlipWords } from "@/components/ui/flip-words"
@@ -35,7 +36,7 @@ export function AuthHeroStatic() {
</div>
</div>
<Link
href="https://www.rustfs.com"
href={withUtm(theme.links.website ?? "https://www.rustfs.com", "login-hero")}
className="z-10 inline-flex h-10 w-max items-center gap-2 rounded-none border border-primary/30 px-5 leading-none text-primary transition-colors hover:bg-accent"
>
<span>{t("Visit website")}</span>
+2 -1
View File
@@ -14,6 +14,7 @@ import { ThemeLogo } from "@/components/theme/logo"
import { AuthHeroStatic } from "@/components/auth/heroes/hero-static"
import { buildRoute } from "@/lib/routes"
import { getThemeManifest } from "@/lib/theme/manifest"
import { withUtm } from "@/lib/utm"
import type { OidcProvider } from "@/types/config"
@@ -54,7 +55,7 @@ export function LoginForm({
}: LoginFormProps) {
const { t } = useTranslation()
const theme = getThemeManifest()
const website = theme.links.website ?? "https://www.rustfs.com"
const website = withUtm(theme.links.website ?? "https://www.rustfs.com", "login-form")
return (
<div className="relative flex min-h-dvh flex-col items-center justify-center overflow-hidden bg-muted p-4 sm:p-8 lg:p-12">
+5 -1
View File
@@ -9,6 +9,7 @@ import { Card, CardContent } from "@/components/ui/card"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table/data-table"
import { useDataTable } from "@/hooks/use-data-table"
import { withUtm } from "@/lib/utm"
import type { ColumnDef } from "@tanstack/react-table"
const hasValidLicense = false
@@ -208,7 +209,10 @@ export function LicenseEnterpriseSection() {
const contactSupport = () => {
window.open(
"https://ww18.53kf.com/webCompany.php?arg=11003151&kf_sign=DA4MDMTc0Ng4MjE1MjEzODAyNDkyMDAyNzMwMDMxNTE%253D&style=2",
withUtm(
"https://ww18.53kf.com/webCompany.php?arg=11003151&kf_sign=DA4MDMTc0Ng4MjE1MjEzODAyNDkyMDAyNzMwMDMxNTE%253D&style=2",
"license-page",
),
"_blank",
"noopener,noreferrer",
)
+2 -1
View File
@@ -3,6 +3,7 @@
import { Button } from "@/components/ui/button"
import { RiGithubLine } from "@remixicon/react"
import { getThemeManifest } from "@/lib/theme/manifest"
import { withUtm } from "@/lib/utm"
export function LinksGithub() {
const github = getThemeManifest().links.github
@@ -13,7 +14,7 @@ export function LinksGithub() {
variant="ghost"
size="icon"
nativeButton={false}
render={<a href={github} target="_blank" rel="noopener noreferrer" aria-label="GitHub" />}
render={<a href={withUtm(github, "top-nav")} target="_blank" rel="noopener noreferrer" aria-label="GitHub" />}
>
<RiGithubLine className="size-4" aria-hidden />
</Button>
+2 -1
View File
@@ -3,6 +3,7 @@
import { Button } from "@/components/ui/button"
import { RiTwitterXLine } from "@remixicon/react"
import { getThemeManifest } from "@/lib/theme/manifest"
import { withUtm } from "@/lib/utm"
export function LinksX() {
const x = getThemeManifest().links.x
@@ -13,7 +14,7 @@ export function LinksX() {
variant="ghost"
size="icon"
nativeButton={false}
render={<a href={x} target="_blank" rel="noopener noreferrer" aria-label="X" />}
render={<a href={withUtm(x, "top-nav")} target="_blank" rel="noopener noreferrer" aria-label="X" />}
>
<RiTwitterXLine className="size-4" aria-hidden />
</Button>
+39
View File
@@ -0,0 +1,39 @@
const UTM_SOURCE = "rustfs-console"
const UTM_MEDIUM = "referral"
/**
* Append UTM tracking params to an external (http/https) URL.
*
* URLs that already carry any utm_* param are returned unchanged so
* theme authors keep full control over their own tracking.
*
* @param content optional placement label (e.g. "sidebar") for utm_content
*/
export function withUtm(url: string, content?: string): string {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return url
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return url
}
for (const key of parsed.searchParams.keys()) {
if (key.toLowerCase().startsWith("utm_")) {
return url
}
}
const utm = new URLSearchParams({ utm_source: UTM_SOURCE, utm_medium: UTM_MEDIUM })
if (content) {
utm.set("utm_content", content)
}
// Concatenate instead of searchParams.set() so existing params stay
// byte-identical (some third-party URLs carry signed values).
parsed.search = parsed.search ? `${parsed.search}&${utm}` : `?${utm}`
return parsed.toString()
}
+53
View File
@@ -0,0 +1,53 @@
import test from "node:test"
import assert from "node:assert/strict"
import { withUtm } from "../../lib/utm"
test("withUtm appends utm params to a bare external URL", () => {
assert.equal(
withUtm("https://www.rustfs.com"),
"https://www.rustfs.com/?utm_source=rustfs-console&utm_medium=referral",
)
})
test("withUtm sets utm_content when a placement is given", () => {
assert.equal(
withUtm("https://www.rustfs.com", "login-hero"),
"https://www.rustfs.com/?utm_source=rustfs-console&utm_medium=referral&utm_content=login-hero",
)
})
test("withUtm preserves existing query params", () => {
assert.equal(
withUtm("https://docs.rustfs.com/?ref=console", "sidebar"),
"https://docs.rustfs.com/?ref=console&utm_source=rustfs-console&utm_medium=referral&utm_content=sidebar",
)
})
test("withUtm inserts params before the hash fragment", () => {
assert.equal(
withUtm("https://docs.rustfs.com/guide#install"),
"https://docs.rustfs.com/guide?utm_source=rustfs-console&utm_medium=referral#install",
)
})
test("withUtm keeps percent-encoded param values byte-identical", () => {
assert.equal(
withUtm("https://example.com/page.php?sign=AbC%253D&style=2"),
"https://example.com/page.php?sign=AbC%253D&style=2&utm_source=rustfs-console&utm_medium=referral",
)
})
test("withUtm leaves URLs that already carry utm params unchanged", () => {
assert.equal(
withUtm("https://www.rustfs.com/?utm_source=partner", "sidebar"),
"https://www.rustfs.com/?utm_source=partner",
)
})
test("withUtm leaves relative URLs unchanged", () => {
assert.equal(withUtm("/browser", "sidebar"), "/browser")
})
test("withUtm leaves non-http protocols unchanged", () => {
assert.equal(withUtm("mailto:hello@rustfs.com"), "mailto:hello@rustfs.com")
})