feat: update version to 0.1.1, upgrade @ciphera-net/ui to 0.0.43, and enhance Campaigns component with URL builder modal
This commit is contained in:
@@ -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
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-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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,14 +70,23 @@ 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>
|
||||||
{showViewAll && (
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsBuilderOpen(true)}
|
||||||
className="text-xs font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
View All
|
<PlusIcon className="w-3.5 h-3.5" />
|
||||||
|
Build URL
|
||||||
</button>
|
</button>
|
||||||
)}
|
{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"
|
||||||
|
>
|
||||||
|
View All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
170
components/tools/UtmBuilder.tsx
Normal file
170
components/tools/UtmBuilder.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
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 ''
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
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
12
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user