diff --git a/app/layout-content.tsx b/app/layout-content.tsx index f279464..b037f67 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -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 ? ( + + Tools + + ) : null + } />
+

Tools

+
+

UTM Campaign Builder

+ +
+ + ) +} diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index 580466f..5bee4ee 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -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([]) const [isLoading, setIsLoading] = useState(true) const [isModalOpen, setIsModalOpen] = useState(false) + const [isBuilderOpen, setIsBuilderOpen] = useState(false) const [fullData, setFullData] = useState([]) const [isLoadingFull, setIsLoadingFull] = useState(false) @@ -67,14 +70,25 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) {

Campaigns

- {showViewAll && ( - - )} + + Build URL + + {showViewAll && ( + + )} + {isLoading ? ( @@ -167,6 +181,16 @@ export default function Campaigns({ siteId, dateRange }: CampaignsProps) { )} + + setIsBuilderOpen(false)} + title="Campaign URL Builder" + > +
+ +
+
) } diff --git a/components/tools/UtmBuilder.tsx b/components/tools/UtmBuilder.tsx new file mode 100644 index 0000000..2c7a67c --- /dev/null +++ b/components/tools/UtmBuilder.tsx @@ -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([]) + const [selectedSiteId, setSelectedSiteId] = useState(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) + 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) => { + setValues({ ...values, [e.target.name]: e.target.value }) + } + + // Helper to handle path changes when a site is selected + const handlePathChange = (e: React.ChangeEvent) => { + 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 ( +
+
+ + {/* Site Selector */} + {sites.length > 0 && ( +
+ + +
+ ) : ( + + )} +

+ You can add specific paths (e.g., /blog/post-1) to the URL above. +

+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + {generatedUrl && ( +
+ {generatedUrl} + +
+ )} + + ) +} diff --git a/package-lock.json b/package-lock.json index 1574eb3..8c5e6f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 02bf60a..73c0538 100644 --- a/package.json +++ b/package.json @@ -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",