[PULSE-41] Implement UTM Campaign URL Builder & Tools #7

Merged
uz1mani merged 5 commits from staging into main 2026-02-04 19:58:19 +00:00
6 changed files with 230 additions and 14 deletions
Showing only changes of commit f2927098ac - Show all commits

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { OfflineBanner } from '@/components/OfflineBanner' import { OfflineBanner } from '@/components/OfflineBanner'
import { Header, Footer } from '@ciphera-net/ui' import { Header, Footer, GridIcon } from '@ciphera-net/ui'
import { useAuth } from '@/lib/auth/context' import { useAuth } from '@/lib/auth/context'
import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
import Link from 'next/link' import Link from 'next/link'
@@ -62,6 +62,15 @@ export default function LayoutContent({ children }: { children: React.ReactNode
showSecurity={false} showSecurity={false}
showPricing={true} showPricing={true}
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined} topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
userMenuCustomItems={
<Link
href="/tools"
className="group flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<GridIcon className="h-4 w-4 text-neutral-500 group-hover:text-neutral-900 dark:text-neutral-400 dark:group-hover:text-white" />
Tools
</Link>
}
/> />
<main <main
className={`flex-1 pb-8 ${showOfflineBar ? '' : 'pt-24'}`} className={`flex-1 pb-8 ${showOfflineBar ? '' : 'pt-24'}`}

15
app/tools/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
'use client'
import UtmBuilder from '@/components/tools/UtmBuilder'
export default function ToolsPage() {
return (
<div className="max-w-2xl mx-auto py-10 px-4">
<h1 className="text-2xl font-bold mb-6 text-neutral-900 dark:text-white">Tools</h1>
<div className="bg-white dark:bg-neutral-900 p-6 rounded-xl border border-neutral-200 dark:border-neutral-800">
<h2 className="text-lg font-semibold mb-4 text-neutral-900 dark:text-white">UTM Campaign Builder</h2>
<UtmBuilder />
</div>
</div>
)
}

View File

@@ -6,6 +6,8 @@ import { formatNumber } from '@/lib/utils/format'
import { Modal, ArrowRightIcon } from '@ciphera-net/ui' import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
import { getCampaigns, CampaignStat } from '@/lib/api/stats' import { getCampaigns, CampaignStat } from '@/lib/api/stats'
import { FaBullhorn } from 'react-icons/fa' import { FaBullhorn } from 'react-icons/fa'
import { PlusIcon } from '@radix-ui/react-icons'
import UtmBuilder from '@/components/tools/UtmBuilder'
interface CampaignsProps { interface CampaignsProps {
siteId: string siteId: string
@@ -18,6 +20,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
const [data, setData] = useState<CampaignStat[]>([]) const [data, setData] = useState<CampaignStat[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [isBuilderOpen, setIsBuilderOpen] = useState(false)
const [fullData, setFullData] = useState<CampaignStat[]>([]) const [fullData, setFullData] = useState<CampaignStat[]>([])
const [isLoadingFull, setIsLoadingFull] = useState(false) const [isLoadingFull, setIsLoadingFull] = useState(false)
@@ -67,6 +70,14 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white"> <h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
Campaigns Campaigns
</h3> </h3>
<div className="flex items-center gap-3">
<button
onClick={() => setIsBuilderOpen(true)}
className="flex items-center gap-1.5 text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors"
>
<PlusIcon className="w-3.5 h-3.5" />
Build URL
</button>
{showViewAll && ( {showViewAll && (
<button <button
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
@@ -76,6 +87,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
</button> </button>
)} )}
</div> </div>
</div>
{isLoading ? ( {isLoading ? (
<div className="space-y-2 flex-1 min-h-[270px] flex flex-col items-center justify-center"> <div className="space-y-2 flex-1 min-h-[270px] flex flex-col items-center justify-center">
@@ -167,6 +179,16 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
)} )}
</div> </div>
</Modal> </Modal>
<Modal
isOpen={isBuilderOpen}
onClose={() => setIsBuilderOpen(false)}
title="Campaign URL Builder"
>
<div className="p-1">
<UtmBuilder initialSiteId={siteId} />
</div>
</Modal>
</> </>
) )
} }

View File

@@ -0,0 +1,170 @@
'use client'
greptile-apps[bot] commented 2026-02-04 19:53:13 +00:00 (Migrated from github.com)
Review

The useEffect references values.url on line 39 but doesn't include it in the dependency array on line 49. This violates React's exhaustive-deps rule and could cause stale closures.

  }, [initialSiteId, values.url])
Prompt To Fix With AI
This is a comment left during a code review.
Path: components/tools/UtmBuilder.tsx
Line: 27:49

Comment:
The `useEffect` references `values.url` on line 39 but doesn't include it in the dependency array on line 49. This violates React's exhaustive-deps rule and could cause stale closures.

```suggestion
  }, [initialSiteId, values.url])
```

How can I resolve this? If you propose a fix, please make it concise.
The `useEffect` references `values.url` on line 39 but doesn't include it in the dependency array on line 49. This violates React's exhaustive-deps rule and could cause stale closures. ```suggestion }, [initialSiteId, values.url]) ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/tools/UtmBuilder.tsx Line: 27:49 Comment: The `useEffect` references `values.url` on line 39 but doesn't include it in the dependency array on line 49. This violates React's exhaustive-deps rule and could cause stale closures. ```suggestion }, [initialSiteId, values.url]) ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-04 19:56:59 +00:00 (Migrated from github.com)
Review

Resolved the React hook dependency warning by splitting the logic into two separate useEffect hooks:
Data Fetching: Runs only once on mount ([] dependencies) to fetch the list of sites.
Initialization: Runs when sites, initialSiteId, selectedSiteId, or values.url changes. It correctly handles setting the default site without causing infinite loops or stale closures, ensuring the linter is happy and the behavior remains correct (including respecting user input if they type before sites load).

Resolved the React hook dependency warning by splitting the logic into two separate useEffect hooks: Data Fetching: Runs only once on mount ([] dependencies) to fetch the list of sites. Initialization: Runs when sites, initialSiteId, selectedSiteId, or values.url changes. It correctly handles setting the default site without causing infinite loops or stale closures, ensuring the linter is happy and the behavior remains correct (including respecting user input if they type before sites load).
import { useState, useEffect } from 'react'
import { CopyIcon, CheckIcon } from '@radix-ui/react-icons'
import { listSites, Site } from '@/lib/api/sites'
import { Select } from '@ciphera-net/ui'
interface UtmBuilderProps {
initialSiteId?: string
}
export default function UtmBuilder({ initialSiteId }: UtmBuilderProps) {
const [sites, setSites] = useState<Site[]>([])
const [selectedSiteId, setSelectedSiteId] = useState<string>(initialSiteId || '')
const [values, setValues] = useState({
url: '',
source: '',
medium: '',
campaign: '',
term: '',
content: ''
})
const [copied, setCopied] = useState(false)
// 1. Fetch sites on mount
useEffect(() => {
async function fetchSites() {
try {
const data = await listSites()
setSites(data)
// If we have an initialSiteId, try to find it and set the URL
if (initialSiteId) {
const site = data.find(s => s.id === initialSiteId)
if (site) {
setValues(v => ({ ...v, url: `https://${site.domain}` }))
}
} else if (data.length > 0 && !values.url) {
// Optional: Default to first site if no initial ID provided
setSelectedSiteId(data[0].id)
setValues(v => ({ ...v, url: `https://${data[0].domain}` }))
}
} catch (e) {
console.error('Failed to load sites for UTM builder', e)
}
}
fetchSites()
}, [initialSiteId])
// 2. Handle Site Selection
const handleSiteChange = (siteId: string) => {
setSelectedSiteId(siteId)
const site = sites.find(s => s.id === siteId)
if (site) {
setValues(prev => ({
...prev,
url: `https://${site.domain}` // Reset URL to base domain of selected site
}))
}
}
const generatedUrl = (() => {
if (!values.url || !values.source || !values.medium || !values.campaign) return ''
try {
const url = new URL(values.url)
url.searchParams.set('utm_source', values.source.toLowerCase())
url.searchParams.set('utm_medium', values.medium.toLowerCase())
url.searchParams.set('utm_campaign', values.campaign.toLowerCase())
if (values.term) url.searchParams.set('utm_term', values.term)
if (values.content) url.searchParams.set('utm_content', values.content)
return url.toString()
} catch (e) {
return ''
}
})()
greptile-apps[bot] commented 2026-02-04 20:06:50 +00:00 (Migrated from github.com)
Review

Inconsistent casing: utm_term and utm_content aren't lowercased like the other UTM parameters

      if (values.term) url.searchParams.set('utm_term', values.term.toLowerCase())
      if (values.content) url.searchParams.set('utm_content', values.content.toLowerCase())

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: components/tools/UtmBuilder.tsx
Line: 75:76

Comment:
Inconsistent casing: `utm_term` and `utm_content` aren't lowercased like the other UTM parameters

```suggestion
      if (values.term) url.searchParams.set('utm_term', values.term.toLowerCase())
      if (values.content) url.searchParams.set('utm_content', values.content.toLowerCase())
```

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.
Inconsistent casing: `utm_term` and `utm_content` aren't lowercased like the other UTM parameters ```suggestion if (values.term) url.searchParams.set('utm_term', values.term.toLowerCase()) if (values.content) url.searchParams.set('utm_content', values.content.toLowerCase()) ``` <sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub> <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: components/tools/UtmBuilder.tsx Line: 75:76 Comment: Inconsistent casing: `utm_term` and `utm_content` aren't lowercased like the other UTM parameters ```suggestion if (values.term) url.searchParams.set('utm_term', values.term.toLowerCase()) if (values.content) url.searchParams.set('utm_content', values.content.toLowerCase()) ``` <sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub> How can I resolve this? If you propose a fix, please make it concise. ````` </details>
const copyToClipboard = () => {
if (!generatedUrl) return
navigator.clipboard.writeText(generatedUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValues({ ...values, [e.target.name]: e.target.value })
}
return (
<div className="space-y-4">
<div className="grid gap-4">
{/* Site Selector */}
{sites.length > 0 && (
<div>
<label className="block text-sm font-medium mb-1 text-neutral-900 dark:text-white">Select Site</label>
<Select
value={selectedSiteId}
onChange={handleSiteChange}
options={sites.map(s => ({ value: s.id, label: s.domain }))}
variant="input"
fullWidth
placeholder="Choose a website..."
/>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1 text-neutral-900 dark:text-white">Website URL *</label>
<input
name="url"
placeholder="https://example.com/landing-page"
className="w-full p-2 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50 focus:ring-2 focus:ring-brand-orange/20 outline-none transition-all text-neutral-900 dark:text-white"
value={values.url}
onChange={handleChange}
/>
<p className="text-xs text-neutral-500 mt-1">
You can add specific paths (e.g., /blog/post-1) to the URL above.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1 text-neutral-900 dark:text-white">Source *</label>
<input
name="source"
placeholder="google, newsletter"
className="w-full p-2 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50 focus:ring-2 focus:ring-brand-orange/20 outline-none transition-all text-neutral-900 dark:text-white"
value={values.source}
onChange={handleChange}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-neutral-900 dark:text-white">Medium *</label>
<input
name="medium"
placeholder="cpc, email"
className="w-full p-2 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50 focus:ring-2 focus:ring-brand-orange/20 outline-none transition-all text-neutral-900 dark:text-white"
value={values.medium}
onChange={handleChange}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-neutral-900 dark:text-white">Campaign Name *</label>
<input
name="campaign"
placeholder="spring_sale"
className="w-full p-2 rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-900/50 focus:ring-2 focus:ring-brand-orange/20 outline-none transition-all text-neutral-900 dark:text-white"
value={values.campaign}
onChange={handleChange}
/>
</div>
</div>
{generatedUrl && (
<div className="mt-6 p-4 bg-neutral-50 dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 flex items-center justify-between group">
<code className="text-sm break-all text-brand-orange font-mono">{generatedUrl}</code>
<button
onClick={copyToClipboard}
className="ml-2 p-2 hover:bg-neutral-200 dark:hover:bg-neutral-800 rounded-lg transition-colors text-neutral-500 hover:text-neutral-900 dark:hover:text-white"
title="Copy to clipboard"
>
{copied ? <CheckIcon className="w-5 h-5 text-green-500" /> : <CopyIcon className="w-5 h-5" />}
</button>
</div>
)}
</div>
)
}

12
package-lock.json generated
View File

@@ -1,14 +1,14 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.1.0", "version": "0.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.1.0", "version": "0.1.1",
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.0.42", "@ciphera-net/ui": "^0.0.43",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"axios": "^1.13.2", "axios": "^1.13.2",
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",
@@ -1467,9 +1467,9 @@
} }
}, },
"node_modules/@ciphera-net/ui": { "node_modules/@ciphera-net/ui": {
"version": "0.0.42", "version": "0.0.43",
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.42/f3476f7f1e6e2210b4c7b1ae84964777e1b2b718", "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.43/d3b37aacb407bf0343d66a4ad5a84de1b5edd712",
"integrity": "sha512-PuzwXKR2DrtTWXELDFH5GhQxnz0qPHTNGtMbTdhslWEp/taEy+n3UsoBp+NFw0uQEvz7mHQ9PgDpDVztQncMfw==", "integrity": "sha512-q1giUPWB5+/CIVAAf1H+3bROCBowNy43bCzq1pycIUsm30lF9NkIqnikCAmvfVJLbtt5qJw7GdIgD39gj6awog==",
"dependencies": { "dependencies": {
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -10,7 +10,7 @@
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.0.42", "@ciphera-net/ui": "^0.0.43",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
"axios": "^1.13.2", "axios": "^1.13.2",
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",