[PULSE-41] Implement UTM Campaign URL Builder & Tools #7
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
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 { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
||||
import Link from 'next/link'
|
||||
@@ -62,6 +62,16 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
||||
showSecurity={false}
|
||||
showPricing={true}
|
||||
topOffset={showOfflineBar ? `${barHeightRem}rem` : undefined}
|
||||
customNavItems={
|
||||
auth.user ? (
|
||||
<Link
|
||||
href="/tools"
|
||||
className="px-4 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white rounded-lg hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 transition-all duration-200"
|
||||
>
|
||||
Tools
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<main
|
||||
className={`flex-1 pb-8 ${showOfflineBar ? '' : 'pt-24'}`}
|
||||
|
||||
15
app/tools/page.tsx
Normal file
15
app/tools/page.tsx
Normal 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-2xl 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>
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { formatNumber } from '@/lib/utils/format'
|
||||
import { Modal, ArrowRightIcon } from '@ciphera-net/ui'
|
||||
import { Modal, ArrowRightIcon, Button } from '@ciphera-net/ui'
|
||||
import { getCampaigns, CampaignStat } from '@/lib/api/stats'
|
||||
import { FaBullhorn } from 'react-icons/fa'
|
||||
import { PlusIcon } from '@radix-ui/react-icons'
|
||||
import UtmBuilder from '@/components/tools/UtmBuilder'
|
||||
|
||||
interface CampaignsProps {
|
||||
siteId: string
|
||||
@@ -18,6 +20,7 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
const [data, setData] = useState<CampaignStat[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isBuilderOpen, setIsBuilderOpen] = useState(false)
|
||||
const [fullData, setFullData] = useState<CampaignStat[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
|
||||
@@ -67,14 +70,25 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">
|
||||
Campaigns
|
||||
</h3>
|
||||
{showViewAll && (
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors"
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsBuilderOpen(true)}
|
||||
className="h-8 px-3 text-xs gap-1.5"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
)}
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
Build URL
|
||||
</Button>
|
||||
{showViewAll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="h-8 px-3 text-xs"
|
||||
>
|
||||
View All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -167,6 +181,16 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={isBuilderOpen}
|
||||
onClose={() => setIsBuilderOpen(false)}
|
||||
title="Campaign URL Builder"
|
||||
>
|
||||
<div className="p-1">
|
||||
<UtmBuilder initialSiteId={siteId} />
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
213
components/tools/UtmBuilder.tsx
Normal file
213
components/tools/UtmBuilder.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CopyIcon, CheckIcon } from '@radix-ui/react-icons'
|
||||
import { listSites, Site } from '@/lib/api/sites'
|
||||
import { Select, Input, Button } 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)
|
||||
} catch (e) {
|
||||
console.error('Failed to load sites for UTM builder', e)
|
||||
}
|
||||
}
|
||||
fetchSites()
|
||||
}, [])
|
||||
|
||||
// 2. Initialize default selection
|
||||
useEffect(() => {
|
||||
if (sites.length === 0) return
|
||||
|
||||
if (initialSiteId) {
|
||||
const site = sites.find(s => s.id === initialSiteId)
|
||||
if (site && selectedSiteId !== site.id) {
|
||||
setSelectedSiteId(site.id)
|
||||
setValues(v => ({ ...v, url: `https://${site.domain}` }))
|
||||
}
|
||||
} else if (!selectedSiteId && !values.url) {
|
||||
const firstSite = sites[0]
|
||||
setSelectedSiteId(firstSite.id)
|
||||
setValues(v => ({ ...v, url: `https://${firstSite.domain}` }))
|
||||
}
|
||||
}, [sites, initialSiteId, selectedSiteId, values.url])
|
||||
|
||||
// 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)
|
||||
|
Inconsistent casing: 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 AIInconsistent 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>
|
||||
return url.toString()
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
})()
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
// Helper to handle path changes when a site is selected
|
||||
const handlePathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const site = sites.find(s => s.id === selectedSiteId)
|
||||
if (!site) return
|
||||
|
||||
const path = e.target.value
|
||||
// Ensure path starts with / if not empty
|
||||
const safePath = path.startsWith('/') || path === '' ? path : `/${path}`
|
||||
setValues(v => ({ ...v, url: `https://${site.domain}${safePath}` }))
|
||||
}
|
||||
|
||||
// Extract path from current URL if site is selected
|
||||
const getCurrentPath = () => {
|
||||
const site = sites.find(s => s.id === selectedSiteId)
|
||||
if (!site || !values.url) return ''
|
||||
|
||||
try {
|
||||
const urlObj = new URL(values.url)
|
||||
return urlObj.pathname === '/' ? '' : urlObj.pathname
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const selectedSite = sites.find(s => s.id === selectedSiteId)
|
||||
|
||||
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.5 text-neutral-900 dark:text-white">Website URL *</label>
|
||||
{selectedSite ? (
|
||||
<div className="flex rounded-xl shadow-sm transition-all duration-200 focus-within:ring-4 focus-within:ring-brand-orange/10 focus-within:border-brand-orange hover:border-brand-orange/50 border border-neutral-200 dark:border-neutral-800">
|
||||
<span className="inline-flex items-center px-4 rounded-l-xl border-r border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 text-neutral-500 text-sm select-none">
|
||||
https://{selectedSite.domain}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-0 block w-full px-4 py-3 rounded-none rounded-r-xl bg-neutral-50/50 dark:bg-neutral-900/50 outline-none transition-all text-neutral-900 dark:text-white text-sm placeholder:text-neutral-400"
|
||||
placeholder="/blog/post-1"
|
||||
value={getCurrentPath()}
|
||||
onChange={handlePathChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
name="url"
|
||||
placeholder="https://example.com/landing-page"
|
||||
value={values.url}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
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.5 text-neutral-900 dark:text-white">Source *</label>
|
||||
<Input
|
||||
name="source"
|
||||
placeholder="google, newsletter"
|
||||
value={values.source}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-neutral-900 dark:text-white">Medium *</label>
|
||||
<Input
|
||||
name="medium"
|
||||
placeholder="cpc, email"
|
||||
value={values.medium}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-neutral-900 dark:text-white">Campaign Name *</label>
|
||||
<Input
|
||||
name="campaign"
|
||||
placeholder="spring_sale"
|
||||
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
|
||||
variant="secondary"
|
||||
onClick={copyToClipboard}
|
||||
className="ml-4 shrink-0 h-9 w-9 p-0 rounded-lg"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-4 h-4 text-green-500" /> : <CopyIcon className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.0.42",
|
||||
"@ciphera-net/ui": "^0.0.44",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"axios": "^1.13.2",
|
||||
"country-flag-icons": "^1.6.4",
|
||||
@@ -1467,9 +1467,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ciphera-net/ui": {
|
||||
"version": "0.0.42",
|
||||
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.42/f3476f7f1e6e2210b4c7b1ae84964777e1b2b718",
|
||||
"integrity": "sha512-PuzwXKR2DrtTWXELDFH5GhQxnz0qPHTNGtMbTdhslWEp/taEy+n3UsoBp+NFw0uQEvz7mHQ9PgDpDVztQncMfw==",
|
||||
"version": "0.0.44",
|
||||
"resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.44/a36bb829498560c7dc49e105e048fdc02e6735d9",
|
||||
"integrity": "sha512-3dgHoVwnYqbKVKC7Dzjzm3sbPHoL+t3J58TC0XvH6S9OYBW1vC+nkF3Jxqq6pVoHOblpZ1/ZokL7hA6xZFeSIQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"clsx": "^2.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -10,7 +10,7 @@
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.0.42",
|
||||
"@ciphera-net/ui": "^0.0.44",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"axios": "^1.13.2",
|
||||
"country-flag-icons": "^1.6.4",
|
||||
|
||||
Reference in New Issue
Block a user
The
useEffectreferencesvalues.urlon 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.Prompt To Fix With AI
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).