Utm Campaign Builder
blockmarketingGoogle Analytics 4 (GA4) campaign link generator with preset presets, shortlink generator, syntax-highlighted URL preview, parameter verification table, and vector QR code export.
Installation
$pnpm dlx uipkge-ng@latest add utm-campaign-builder$npx uipkge-ng@latest add utm-campaign-builder$yarn dlx uipkge-ng@latest add utm-campaign-builder$bunx uipkge-ng@latest add utm-campaign-builderManifest:
https://uipkge.dev/r/angular/utm-campaign-builder.jsonInstalls to:src/app/components/blocks/utm-campaign-builder/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
UtmPresetinterface UtmPreset {
id: string
name: string
description: string
baseUrl: string
source: string
medium: string
campaign: string
term?: string
content?: string
shortlinkSlug: string
}UtmCampaignDatainterface UtmCampaignData {
baseUrl: string
source: string
medium: string
campaign: string
term: string
content: string
fullUrl: string
shortlinkUrl: string
}QueryParamIteminterface QueryParamItem {
key: string
value: string
label: string
required: boolean
}Files installed (2)
src/app/components/blocks/utm-campaign-builder/utm-campaign-builder.component.ts44.9 kB
// Google Analytics 4 (GA4) campaign link generator with campaign presets, shortlink // generator, syntax-highlighted URL preview, parameter verification table, and vector // QR code export. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, EventEmitter, Input, Output, computed, effect, inject, signal, } from '@angular/core' import { Check, CheckCircle2, Copy, Download, ExternalLink, Globe, Link2, LucideAngularModule, QrCode, RotateCcw, Tag, TrendingUp, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' export interface UtmPreset { id: string name: string description: string baseUrl: string source: string medium: string campaign: string term?: string content?: string shortlinkSlug: string } export interface UtmCampaignData { baseUrl: string source: string medium: string campaign: string term: string content: string fullUrl: string shortlinkUrl: string } export const CAMPAIGN_PRESETS: UtmPreset[] = [ { id: 'product_launch', name: 'Product Launch', description: 'SaaS feature launch across social platforms', baseUrl: 'https://uipkge.dev/pricing', source: 'twitter', medium: 'social', campaign: 'launch_2026_q3', term: 'ui_components', content: 'banner_hero_cta', shortlinkSlug: 'q3-launch', }, { id: 'weekly_newsletter', name: 'Weekly Newsletter', description: 'Substack / Mailchimp subscriber digest campaign', baseUrl: 'https://uipkge.dev/blog', source: 'newsletter', medium: 'email', campaign: 'weekly_digest_issue_48', term: 'developer_tools', content: 'featured_article_button', shortlinkSlug: 'digest-48', }, { id: 'google_search_ads', name: 'Google Search Ads', description: 'Targeted Google Search CPC keyword campaign', baseUrl: 'https://uipkge.dev', source: 'google', medium: 'cpc', campaign: 'search_brand_core_us', term: 'vue_react_components', content: 'headline_v2_price_test', shortlinkSlug: 'google-ads', }, { id: 'twitter_promo', name: 'Twitter / X Promo', description: 'Promotional & organic posts on X/Twitter', baseUrl: 'https://uipkge.dev/components', source: 'twitter', medium: 'social', campaign: 'flash_discount_august', term: 'lifetime_deal', content: 'video_card_preview', shortlinkSlug: 'x-promo', }, ] const SOURCE_CHIPS = [ { label: 'twitter', display: 'Twitter / X' }, { label: 'google', display: 'Google' }, { label: 'newsletter', display: 'Newsletter' }, { label: 'linkedin', display: 'LinkedIn' }, { label: 'facebook', display: 'Meta / FB' }, { label: 'youtube', display: 'YouTube' }, ] const MEDIUM_CHIPS = [ { label: 'social', display: 'social' }, { label: 'cpc', display: 'cpc' }, { label: 'email', display: 'email' }, { label: 'referral', display: 'referral' }, { label: 'organic_social', display: 'organic_social' }, { label: 'display', display: 'display' }, ] interface QueryParamItem { key: string value: string label: string required: boolean } // Standalone Pure QR Matrix Generator (Byte mode, standard GF(256) Reed-Solomon) function generateQrMatrix(text: string): { matrix: boolean[][]; size: number } { const bytes: number[] = [] for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i) if (code < 0x80) bytes.push(code) else if (code < 0x800) { bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f)) } else { bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f)) } } const versions = [ { version: 1, size: 21, dataCap: 16, ecWords: 10, blocks: 1, alignPos: [] as number[] }, { version: 2, size: 25, dataCap: 28, ecWords: 16, blocks: 1, alignPos: [6, 18] }, { version: 3, size: 29, dataCap: 44, ecWords: 26, blocks: 1, alignPos: [6, 22] }, { version: 4, size: 33, dataCap: 64, ecWords: 18, blocks: 2, alignPos: [6, 26] }, { version: 5, size: 37, dataCap: 86, ecWords: 24, blocks: 2, alignPos: [6, 30] }, { version: 6, size: 41, dataCap: 108, ecWords: 16, blocks: 4, alignPos: [6, 34] }, { version: 7, size: 45, dataCap: 124, ecWords: 18, blocks: 4, alignPos: [6, 22, 38] }, { version: 8, size: 49, dataCap: 154, ecWords: 22, blocks: 4, alignPos: [6, 24, 42] }, { version: 9, size: 53, dataCap: 182, ecWords: 22, blocks: 5, alignPos: [6, 26, 46] }, { version: 10, size: 57, dataCap: 216, ecWords: 26, blocks: 5, alignPos: [6, 28, 50] }, ] let v = versions.find((spec) => bytes.length <= spec.dataCap - 3) if (!v) v = versions[versions.length - 1] const size = v.size const matrix: (boolean | null)[][] = Array.from({ length: size }, () => Array(size).fill(null)) const isFunction: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)) function placeFinder(r: number, c: number) { for (let dr = -1; dr <= 7; dr++) { for (let dc = -1; dc <= 7; dc++) { const nr = r + dr const nc = c + dc if (nr >= 0 && nr < size && nc >= 0 && nc < size) { isFunction[nr][nc] = true if (dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6) { const isBorder = dr === 0 || dr === 6 || dc === 0 || dc === 6 const isCenter = dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4 matrix[nr][nc] = isBorder || isCenter } else { matrix[nr][nc] = false } } } } } placeFinder(0, 0) placeFinder(0, size - 7) placeFinder(size - 7, 0) for (const ar of v.alignPos) { for (const ac of v.alignPos) { if ((ar === 6 && ac === 6) || (ar === 6 && ac === size - 7) || (ar === size - 7 && ac === 6)) { continue } if (isFunction[ar][ac]) continue for (let dr = -2; dr <= 2; dr++) { for (let dc = -2; dc <= 2; dc++) { const nr = ar + dr const nc = ac + dc isFunction[nr][nc] = true const isBorder = Math.abs(dr) === 2 || Math.abs(dc) === 2 const isCenter = dr === 0 && dc === 0 matrix[nr][nc] = isBorder || isCenter } } } } for (let i = 0; i < size; i++) { if (!isFunction[6][i]) { isFunction[6][i] = true matrix[6][i] = i % 2 === 0 } if (!isFunction[i][6]) { isFunction[i][6] = true matrix[i][6] = i % 2 === 0 } } isFunction[4 * v.version + 9][8] = true matrix[4 * v.version + 9][8] = true for (let i = 0; i < 9; i++) { if (i !== 6) { isFunction[8][i] = true isFunction[i][8] = true } } for (let i = 0; i < 8; i++) { isFunction[8][size - 1 - i] = true isFunction[size - 1 - i][8] = true } const bitStream: number[] = [] function pushBits(val: number, len: number) { for (let i = len - 1; i >= 0; i--) { bitStream.push((val >> i) & 1) } } pushBits(0b0100, 4) pushBits(bytes.length, 8) for (const b of bytes) { pushBits(b, 8) } const totalDataBits = v.dataCap * 8 const termLen = Math.min(4, totalDataBits - bitStream.length) pushBits(0, termLen) while (bitStream.length % 8 !== 0) { bitStream.push(0) } const padBytes = [0xec, 0x11] let padIdx = 0 while (bitStream.length < totalDataBits) { pushBits(padBytes[padIdx % 2], 8) padIdx++ } const dataWords: number[] = [] for (let i = 0; i < bitStream.length; i += 8) { let word = 0 for (let j = 0; j < 8; j++) { word = (word << 1) | bitStream[i + j] } dataWords.push(word) } const EXP: number[] = new Array(512) const LOG: number[] = new Array(256) let cur = 1 for (let i = 0; i < 255; i++) { EXP[i] = cur EXP[i + 255] = cur LOG[cur] = i cur = (cur << 1) ^ (cur >= 128 ? 0x11d : 0) } function gfMul(a: number, b: number): number { if (a === 0 || b === 0) return 0 return EXP[LOG[a] + LOG[b]] } function getGeneratorPoly(numEc: number): number[] { let poly = [1] for (let i = 0; i < numEc; i++) { const next = new Array(poly.length + 1).fill(0) for (let j = 0; j < poly.length; j++) { next[j] ^= gfMul(poly[j], EXP[i]) next[j + 1] ^= poly[j] } poly = next } return poly } const ecWordsPerBlock = v.ecWords const numBlocks = v.blocks const blockSize = Math.floor(dataWords.length / numBlocks) const rawBlocks: number[][] = [] const ecBlocks: number[][] = [] const genPoly = getGeneratorPoly(ecWordsPerBlock) for (let b = 0; b < numBlocks; b++) { const blockData = dataWords.slice(b * blockSize, (b + 1) * blockSize) rawBlocks.push(blockData) const rem = new Array(blockData.length + ecWordsPerBlock).fill(0) for (let i = 0; i < blockData.length; i++) rem[i] = blockData[i] for (let i = 0; i < blockData.length; i++) { const factor = rem[i] if (factor !== 0) { for (let j = 0; j < genPoly.length; j++) { rem[i + j] ^= gfMul(genPoly[j], factor) } } } ecBlocks.push(rem.slice(blockData.length)) } const finalBits: number[] = [] for (let i = 0; i < blockSize; i++) { for (let b = 0; b < numBlocks; b++) { const val = rawBlocks[b][i] ?? 0 for (let bit = 7; bit >= 0; bit--) { finalBits.push((val >> bit) & 1) } } } for (let i = 0; i < ecWordsPerBlock; i++) { for (let b = 0; b < numBlocks; b++) { const val = ecBlocks[b][i] ?? 0 for (let bit = 7; bit >= 0; bit--) { finalBits.push((val >> bit) & 1) } } } let bitPointer = 0 let right = size - 1 let upward = true while (right > 0) { if (right === 6) right-- const colList = [right, right - 1] const rowList = upward ? Array.from({ length: size }, (_, i) => size - 1 - i) : Array.from({ length: size }, (_, i) => i) for (const r of rowList) { for (const c of colList) { if (!isFunction[r][c]) { let bit = 0 if (bitPointer < finalBits.length) { bit = finalBits[bitPointer++] } const mask = (r + c) % 2 === 0 matrix[r][c] = (bit === 1) !== mask } } } right -= 2 upward = !upward } const formatInfo = [1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0] const tlCoords: [number, number][] = [ [8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8], [7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8], ] for (let i = 0; i < 15; i++) { const [r, c] = tlCoords[i] matrix[r][c] = formatInfo[i] === 1 } for (let i = 0; i < 8; i++) { matrix[8][size - 1 - i] = formatInfo[i] === 1 } for (let i = 8; i < 15; i++) { matrix[size - (15 - i)][8] = formatInfo[i] === 1 } const cleanMatrix: boolean[][] = matrix.map((row) => row.map((cell) => cell === true)) return { matrix: cleanMatrix, size } } function qrToSvgPath(matrix: boolean[][]): string { let path = '' for (let r = 0; r < matrix.length; r++) { for (let c = 0; c < matrix[r].length; c++) { if (matrix[r][c]) { path += `M${c},${r}h1v1h-1z ` } } } return path } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-utm-campaign-builder, [ui-utm-campaign-builder]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, UiInputComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, ], template: ` <div data-slot="utm-campaign-builder" [class]="rootClass"> <!-- Header Section --> <div class="border-border flex flex-col gap-4 border-b pb-5 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1"> <div class="flex flex-wrap items-center gap-2.5"> <div class="bg-primary/10 text-primary border-primary/20 flex size-8 items-center justify-center rounded-md border" > <lucide-icon [img]="TrendingUp" class="size-4" /> </div> <h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">Campaign URL & UTM Builder</h1> <span ui-badge [variant]="isGa4Compliant() ? 'default' : 'outline'" class="gap-1 px-2 py-0.5 text-xs font-semibold" > <lucide-icon [img]="CheckCircle2" class="size-3" /> {{ isGa4Compliant() ? 'GA4 Standard' : 'Draft Link' }} </span> </div> <p class="text-muted-foreground text-xs sm:text-sm"> Generate trackable marketing links with standardized GA4 parameters, shortlinks, and vector QR codes. </p> </div> <!-- Preset Selector & Reset Action --> <div class="flex flex-wrap items-center gap-2"> <div class="w-56"> <ui-select [value]="selectedPreset()" (valueChange)="applyPreset($event)"> <button ui-select-trigger class="h-9 text-xs"> <ui-select-value placeholder="Select campaign preset" /> </button> <ui-select-content> @for (preset of presets; track preset.id) { <ui-select-item [value]="preset.id">{{ preset.name }}</ui-select-item> } <ui-select-item value="custom">Custom Parameters</ui-select-item> </ui-select-content> </ui-select> </div> <button ui-button variant="outline" size="sm" class="h-9 gap-1.5 text-xs" (click)="handleReset()"> <lucide-icon [img]="RotateCcw" class="size-3.5" /> Reset </button> </div> </div> <!-- 2-Column Builder Layout --> <div class="grid grid-cols-1 items-start gap-8 lg:grid-cols-12"> <!-- Left Column: Parameter Inputs Form --> <div class="space-y-6 lg:col-span-7"> <div ui-card> <div ui-card-header class="pb-4"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <lucide-icon [img]="Link2" class="text-primary size-4" /> <h2 ui-card-title class="text-base font-semibold">Campaign Parameters</h2> </div> <span ui-badge variant="secondary" class="font-mono text-xs"> {{ activeQueryParameters().length }} active params </span> </div> <p ui-card-description class="text-xs"> Fill in the landing page destination and key attribution parameters for Google Analytics 4. </p> </div> <div ui-card-content class="space-y-5"> <!-- 1. Base Website URL --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <label for="utm-base-url" class="text-foreground text-xs font-medium"> Website URL <span class="text-destructive">*</span> </label> <span class="text-muted-foreground text-xs">Destination link</span> </div> <ui-input id="utm-base-url" [value]="baseUrl()" (valueChange)="baseUrl.set($event)" type="url" placeholder="https://uipkge.dev/pricing" size="middle" /> <p class="text-muted-foreground text-xs">The full landing page URL where users will arrive.</p> </div> <ui-separator /> <!-- 2. Campaign Source (utm_source) --> <div class="space-y-2"> <div class="flex items-center justify-between"> <div class="flex items-center gap-1.5"> <label for="utm-source" class="text-foreground text-xs font-medium"> Campaign Source <span class="text-destructive">*</span> </label> <code class="bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs" >utm_source</code > </div> <span ui-badge variant="outline" class="text-xs">Required</span> </div> <ui-input id="utm-source" [value]="utmSource()" (valueChange)="utmSource.set($event)" placeholder="e.g. twitter, google, newsletter" size="middle" /> <div class="flex flex-wrap items-center gap-1.5 pt-1"> <span class="text-muted-foreground mr-1 text-xs">Suggestions:</span> @for (chip of sourceChips; track chip.label) { <button type="button" [class]="sourceChipClass(chip.label)" (click)="utmSource.set(chip.label)"> {{ chip.label }} </button> } </div> </div> <!-- 3. Campaign Medium (utm_medium) --> <div class="space-y-2"> <div class="flex items-center justify-between"> <div class="flex items-center gap-1.5"> <label for="utm-medium" class="text-foreground text-xs font-medium"> Campaign Medium <span class="text-destructive">*</span> </label> <code class="bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs" >utm_medium</code > </div> <span ui-badge variant="outline" class="text-xs">Required</span> </div> <ui-input id="utm-medium" [value]="utmMedium()" (valueChange)="utmMedium.set($event)" placeholder="e.g. social, cpc, email, referral" size="middle" /> <div class="flex flex-wrap items-center gap-1.5 pt-1"> <span class="text-muted-foreground mr-1 text-xs">Suggestions:</span> @for (chip of mediumChips; track chip.label) { <button type="button" [class]="mediumChipClass(chip.label)" (click)="utmMedium.set(chip.label)"> {{ chip.label }} </button> } </div> </div> <!-- 4. Campaign Name (utm_campaign) --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <div class="flex items-center gap-1.5"> <label for="utm-campaign" class="text-foreground text-xs font-medium"> Campaign Name <span class="text-destructive">*</span> </label> <code class="bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs" >utm_campaign</code > </div> <span ui-badge variant="outline" class="text-xs">Required</span> </div> <ui-input id="utm-campaign" [value]="utmCampaign()" (valueChange)="utmCampaign.set($event)" placeholder="e.g. launch_2026_q3, summer_promo" size="middle" /> <p class="text-muted-foreground text-xs">Unique promotion identifier, product slogan, or promo code.</p> </div> <ui-separator /> <!-- Optional Parameters Section Header --> <div class="flex items-center gap-2 pt-1"> <lucide-icon [img]="Tag" class="text-muted-foreground size-3.5" /> <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase"> Optional GA4 Attributes </span> </div> <!-- 5. Campaign Term (utm_term) --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <div class="flex items-center gap-1.5"> <label for="utm-term" class="text-foreground text-xs font-medium">Campaign Term</label> <code class="bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs">utm_term</code> </div> <span class="text-muted-foreground text-xs">Optional · Paid search keywords</span> </div> <ui-input id="utm-term" [value]="utmTerm()" (valueChange)="utmTerm.set($event)" placeholder="e.g. ui_components, vue_dashboard" size="middle" /> </div> <!-- 6. Campaign Content (utm_content) --> <div class="space-y-1.5"> <div class="flex items-center justify-between"> <div class="flex items-center gap-1.5"> <label for="utm-content" class="text-foreground text-xs font-medium">Campaign Content</label> <code class="bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs" >utm_content</code > </div> <span class="text-muted-foreground text-xs">Optional · A/B test & ad creative</span> </div> <ui-input id="utm-content" [value]="utmContent()" (valueChange)="utmContent.set($event)" placeholder="e.g. banner_cta_blue, sidebar_link_v2" size="middle" /> </div> </div> </div> </div> <!-- Right Column: Generated Campaign URL Card (Sticky) --> <div class="space-y-5 lg:sticky lg:top-6 lg:col-span-5"> <div ui-card class="border-border overflow-hidden shadow-sm"> <div ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <span class="relative flex size-2"> <span class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75"></span> <span class="bg-success relative inline-flex size-2 rounded-full"></span> </span> <h2 ui-card-title class="text-base font-semibold">Generated Campaign URL</h2> </div> <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium" > Live Ready </span> </div> <p ui-card-description class="text-xs">Formatted destination URL with tracking query parameters.</p> </div> <div ui-card-content class="space-y-4"> <!-- Monospace Syntax-Highlighted Code Container --> <div class="bg-muted/70 border-border/80 relative rounded-lg border p-3.5 font-mono text-xs leading-relaxed break-all select-all" > @if (generatedFullUrl()) { <div> <span class="text-foreground font-semibold">{{ baseUrl().trim() }}</span> @if (activeQueryParameters().length > 0) { <span class="text-muted-foreground font-bold">{{ baseUrl().includes('?') ? '&' : '?' }}</span> @for (param of activeQueryParameters(); track param.key; let last = $last) { <span class="text-info font-medium">{{ param.key }}</span> <span class="text-muted-foreground">=</span> <span class="text-success font-semibold">{{ encodeParam(param.value) }}</span> @if (!last) { <span class="text-muted-foreground font-bold">&</span> } } } </div> } @else { <div class="text-muted-foreground italic"> Enter a destination website URL to generate your campaign link... </div> } </div> <!-- Action Buttons --> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <button ui-button size="sm" class="w-full gap-1.5 text-xs font-semibold" [disabled]="!generatedFullUrl()" (click)="handleCopyFullUrl()" > @if (copiedFullUrl()) { <lucide-icon [img]="Check" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Copy" class="size-3.5" /> } {{ copiedFullUrl() ? 'Copied Full URL!' : 'Copy Full URL' }} </button> <a ui-button variant="outline" size="sm" class="w-full gap-1.5 text-xs" [attr.href]="generatedFullUrl() || null" target="_blank" rel="noopener noreferrer" > <lucide-icon [img]="ExternalLink" class="size-3.5" /> Test in New Tab </a> </div> <ui-separator /> <!-- Shortlink Generator Preview --> <div class="space-y-2"> <div class="flex items-center justify-between"> <div class="flex items-center gap-1.5"> <lucide-icon [img]="Globe" class="text-primary size-3.5" /> <span class="text-foreground text-xs font-semibold">Shortlink Generator</span> </div> <span class="text-muted-foreground text-xs">For SMS & Social</span> </div> <div class="flex items-center gap-2"> <div class="relative flex-1"> <ui-input [value]="shortlinkSlug()" (valueChange)="shortlinkSlug.set($event)" placeholder="custom-slug" size="small" [prefix]="'uipk.ge/'" class="font-mono text-xs" /> </div> <button ui-button variant="outline" size="sm" class="shrink-0 gap-1 text-xs" (click)="handleCopyShortlink()" > @if (copiedShortlink()) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } {{ copiedShortlink() ? 'Copied!' : 'Copy' }} </button> </div> </div> <ui-separator /> <!-- SVG QR Code Generator Pass --> <div class="space-y-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-1.5"> <lucide-icon [img]="QrCode" class="text-primary size-3.5" /> <span class="text-foreground text-xs font-semibold">QR Code Campaign Pass</span> </div> <span ui-badge variant="outline" class="text-xs">Print & Banners</span> </div> <div class="bg-muted/40 border-border/70 flex flex-col items-center justify-center gap-3 rounded-lg border p-4 text-center" > <div class="bg-card text-foreground border-border flex size-36 items-center justify-center rounded-md border p-2 shadow-xs" > <svg xmlns="http://www.w3.org/2000/svg" [attr.viewBox]="'0 0 ' + (qrData().size + 4) + ' ' + (qrData().size + 4)" class="size-full" shape-rendering="crispEdges" > <rect width="100%" height="100%" fill="transparent" /> <g transform="translate(2, 2)"> <path [attr.d]="qrPath()" fill="currentColor" /> </g> </svg> </div> <p class="text-muted-foreground text-xs"> Vector SVG scales to any resolution for billboard, print collateral, or event badges. </p> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="w-full gap-1.5 text-xs" [disabled]="!generatedFullUrl()" (click)="handleDownloadQr()" > @if (downloadedQr()) { <lucide-icon [img]="Check" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Download" class="size-3.5" /> } {{ downloadedQr() ? 'Downloaded SVG!' : 'Download QR Code (.SVG)' }} </button> </div> </div> <ui-separator /> <!-- Parameters Breakdown Table --> <div class="space-y-2"> <div class="flex items-center justify-between"> <span class="text-foreground text-xs font-semibold">Parameters Verification</span> <span class="text-muted-foreground text-xs">{{ activeQueryParameters().length }}/5 Active</span> </div> <div class="border-border overflow-hidden rounded-md border text-xs"> <table ui-table> <thead ui-table-header> <tr ui-table-row class="hover:bg-transparent"> <th ui-table-head class="h-7 text-xs font-medium">Parameter</th> <th ui-table-head class="h-7 text-xs font-medium">Value</th> <th ui-table-head class="h-7 text-right text-xs font-medium">Status</th> </tr> </thead> <tbody ui-table-body> <tr ui-table-row> <td ui-table-cell class="py-2 font-mono text-xs font-semibold">utm_source</td> <td ui-table-cell class="text-muted-foreground py-2 font-mono text-xs"> {{ utmSource().trim() || '—' }} </td> <td ui-table-cell class="py-2 text-right"> <span ui-badge [variant]="utmSource().trim() ? 'default' : 'destructive'" class="px-1.5 py-0 text-xs font-normal" > {{ utmSource().trim() ? 'Set' : 'Missing' }} </span> </td> </tr> <tr ui-table-row> <td ui-table-cell class="py-2 font-mono text-xs font-semibold">utm_medium</td> <td ui-table-cell class="text-muted-foreground py-2 font-mono text-xs"> {{ utmMedium().trim() || '—' }} </td> <td ui-table-cell class="py-2 text-right"> <span ui-badge [variant]="utmMedium().trim() ? 'default' : 'destructive'" class="px-1.5 py-0 text-xs font-normal" > {{ utmMedium().trim() ? 'Set' : 'Missing' }} </span> </td> </tr> <tr ui-table-row> <td ui-table-cell class="py-2 font-mono text-xs font-semibold">utm_campaign</td> <td ui-table-cell class="text-muted-foreground py-2 font-mono text-xs"> {{ utmCampaign().trim() || '—' }} </td> <td ui-table-cell class="py-2 text-right"> <span ui-badge [variant]="utmCampaign().trim() ? 'default' : 'destructive'" class="px-1.5 py-0 text-xs font-normal" > {{ utmCampaign().trim() ? 'Set' : 'Missing' }} </span> </td> </tr> <tr ui-table-row> <td ui-table-cell class="py-2 font-mono text-xs font-semibold">utm_term</td> <td ui-table-cell class="text-muted-foreground py-2 font-mono text-xs"> {{ utmTerm().trim() || '—' }} </td> <td ui-table-cell class="py-2 text-right"> <span ui-badge variant="outline" class="px-1.5 py-0 text-xs font-normal"> {{ utmTerm().trim() ? 'Set' : 'Optional' }} </span> </td> </tr> <tr ui-table-row> <td ui-table-cell class="py-2 font-mono text-xs font-semibold">utm_content</td> <td ui-table-cell class="text-muted-foreground py-2 font-mono text-xs"> {{ utmContent().trim() || '—' }} </td> <td ui-table-cell class="py-2 text-right"> <span ui-badge variant="outline" class="px-1.5 py-0 text-xs font-normal"> {{ utmContent().trim() ? 'Set' : 'Optional' }} </span> </td> </tr> </tbody> </table> </div> </div> </div> </div> </div> </div> </div> `, }) export class UiUtmCampaignBuilderComponent { protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly Copy = Copy protected readonly Download = Download protected readonly ExternalLink = ExternalLink protected readonly Globe = Globe protected readonly Link2 = Link2 protected readonly QrCode = QrCode protected readonly RotateCcw = RotateCcw protected readonly Tag = Tag protected readonly TrendingUp = TrendingUp protected readonly presets = CAMPAIGN_PRESETS protected readonly sourceChips = SOURCE_CHIPS protected readonly mediumChips = MEDIUM_CHIPS @Input() set initialPreset(v: string) { this.selectedPreset.set(v ?? 'product_launch') } @Input() set initialBaseUrl(v: string) { if (v !== undefined) this.baseUrl.set(v) } @Input() set initialSource(v: string) { if (v !== undefined) this.utmSource.set(v) } @Input() set initialMedium(v: string) { if (v !== undefined) this.utmMedium.set(v) } @Input() set initialCampaign(v: string) { if (v !== undefined) this.utmCampaign.set(v) } @Input() set initialTerm(v: string) { if (v !== undefined) this.utmTerm.set(v) } @Input() set initialContent(v: string) { if (v !== undefined) this.utmContent.set(v) } @Input() set initialShortlinkDomain(v: string) { if (v !== undefined) this.shortlinkDomain.set(v) } @Input() set initialShortlinkSlug(v: string) { if (v !== undefined) this.shortlinkSlug.set(v) } @Input('class') className?: string // React onCopyUrl/onCopyShortlink/onDownloadQr/onChange (Vue copy-url/copy-shortlink/download-qr/change). @Output() readonly copyUrl = new EventEmitter<string>() @Output() readonly copyShortlink = new EventEmitter<string>() @Output() readonly downloadQr = new EventEmitter<{ url: string; filename: string }>() @Output() readonly change = new EventEmitter<UtmCampaignData>() readonly selectedPreset = signal('product_launch') readonly baseUrl = signal('https://uipkge.dev/pricing') readonly utmSource = signal('twitter') readonly utmMedium = signal('social') readonly utmCampaign = signal('launch_2026_q3') readonly utmTerm = signal('ui_components') readonly utmContent = signal('banner_hero_cta') readonly shortlinkDomain = signal('https://uipk.ge') readonly shortlinkSlug = signal('q3-launch') readonly copiedFullUrl = signal(false) readonly copiedShortlink = signal(false) readonly downloadedQr = signal(false) private copyUrlTimer: ReturnType<typeof setTimeout> | undefined private copyShortlinkTimer: ReturnType<typeof setTimeout> | undefined private downloadQrTimer: ReturnType<typeof setTimeout> | undefined constructor() { inject(DestroyRef).onDestroy(() => { clearTimeout(this.copyUrlTimer) clearTimeout(this.copyShortlinkTimer) clearTimeout(this.downloadQrTimer) }) // React's onChange useEffect / Vue's immediate change watcher. effect(() => { this.change.emit({ baseUrl: this.baseUrl(), source: this.utmSource(), medium: this.utmMedium(), campaign: this.utmCampaign(), term: this.utmTerm(), content: this.utmContent(), fullUrl: this.generatedFullUrl(), shortlinkUrl: this.generatedShortlink(), }) }) } readonly activeQueryParameters = computed<QueryParamItem[]>(() => { const list: QueryParamItem[] = [] if (this.utmSource().trim()) { list.push({ key: 'utm_source', value: this.utmSource().trim(), label: 'Campaign Source', required: true }) } if (this.utmMedium().trim()) { list.push({ key: 'utm_medium', value: this.utmMedium().trim(), label: 'Campaign Medium', required: true }) } if (this.utmCampaign().trim()) { list.push({ key: 'utm_campaign', value: this.utmCampaign().trim(), label: 'Campaign Name', required: true }) } if (this.utmTerm().trim()) { list.push({ key: 'utm_term', value: this.utmTerm().trim(), label: 'Campaign Term', required: false }) } if (this.utmContent().trim()) { list.push({ key: 'utm_content', value: this.utmContent().trim(), label: 'Campaign Content', required: false }) } return list }) readonly generatedFullUrl = computed(() => { const rawBase = this.baseUrl().trim() if (!rawBase) return '' let normalized = rawBase if (!/^https?:\/\//i.test(normalized)) { normalized = `https://${normalized}` } const params = new URLSearchParams() if (this.utmSource().trim()) params.set('utm_source', this.utmSource().trim()) if (this.utmMedium().trim()) params.set('utm_medium', this.utmMedium().trim()) if (this.utmCampaign().trim()) params.set('utm_campaign', this.utmCampaign().trim()) if (this.utmTerm().trim()) params.set('utm_term', this.utmTerm().trim()) if (this.utmContent().trim()) params.set('utm_content', this.utmContent().trim()) const queryString = params.toString() if (!queryString) return normalized const separator = normalized.includes('?') ? '&' : '?' return `${normalized}${separator}${queryString}` }) readonly generatedShortlink = computed(() => { const domain = this.shortlinkDomain().replace(/\/+$/, '') const slug = this.shortlinkSlug().trim() || this.utmCampaign() .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') || 'link' return `${domain}/${slug}` }) readonly isGa4Compliant = computed(() => Boolean(this.baseUrl().trim() && this.utmSource().trim() && this.utmMedium().trim() && this.utmCampaign().trim()), ) readonly qrData = computed(() => { const text = this.generatedFullUrl() || 'https://uipkge.dev' return generateQrMatrix(text) }) readonly qrPath = computed(() => qrToSvgPath(this.qrData().matrix)) get rootClass(): string { return cn('mx-auto w-full max-w-7xl space-y-6', this.className) } encodeParam(value: string): string { return encodeURIComponent(value) } sourceChipClass(label: string): string { return cn( 'min-h-6 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors', this.utmSource() === label ? 'border-primary bg-primary/10 text-primary font-semibold' : 'border-border bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground', ) } mediumChipClass(label: string): string { return cn( 'min-h-6 rounded-md border px-2 py-0.5 text-xs font-medium transition-colors', this.utmMedium() === label ? 'border-primary bg-primary/10 text-primary font-semibold' : 'border-border bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground', ) } applyPreset(presetId: string): void { const id = String(presetId) this.selectedPreset.set(id) if (id === 'custom') return const preset = CAMPAIGN_PRESETS.find((p) => p.id === id) if (preset) { this.baseUrl.set(preset.baseUrl) this.utmSource.set(preset.source) this.utmMedium.set(preset.medium) this.utmCampaign.set(preset.campaign) this.utmTerm.set(preset.term ?? '') this.utmContent.set(preset.content ?? '') this.shortlinkSlug.set(preset.shortlinkSlug) } } handleReset(): void { this.selectedPreset.set('product_launch') const def = CAMPAIGN_PRESETS[0] this.baseUrl.set(def.baseUrl) this.utmSource.set(def.source) this.utmMedium.set(def.medium) this.utmCampaign.set(def.campaign) this.utmTerm.set(def.term ?? '') this.utmContent.set(def.content ?? '') this.shortlinkSlug.set(def.shortlinkSlug) } async handleCopyFullUrl(): Promise<void> { const url = this.generatedFullUrl() if (!url) return try { await navigator.clipboard.writeText(url) clearTimeout(this.copyUrlTimer) this.copiedFullUrl.set(true) this.copyUrl.emit(url) this.copyUrlTimer = setTimeout(() => this.copiedFullUrl.set(false), 2000) } catch { // Clipboard unavailable (insecure context): leave the button state unchanged. } } async handleCopyShortlink(): Promise<void> { const link = this.generatedShortlink() if (!link) return try { await navigator.clipboard.writeText(link) clearTimeout(this.copyShortlinkTimer) this.copiedShortlink.set(true) this.copyShortlink.emit(link) this.copyShortlinkTimer = setTimeout(() => this.copiedShortlink.set(false), 2000) } catch { // Clipboard unavailable (insecure context): leave the button state unchanged. } } handleDownloadQr(): void { const text = this.generatedFullUrl() || 'https://uipkge.dev' const { matrix, size } = generateQrMatrix(text) const pathData = qrToSvgPath(matrix) const margin = 2 const totalSize = size + margin * 2 const filename = this.utmCampaign().trim() ? `${this.utmCampaign().trim()}-qr.svg` : 'utm-campaign-qr.svg' const svgContent = `<?xml version="1.0" encoding="utf-8"?> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${totalSize} ${totalSize}" width="512" height="512"> <rect width="100%" height="100%" fill="#ffffff" /> <g transform="translate(${margin}, ${margin})"> <path d="${pathData}" fill="#0f172a" shape-rendering="crispEdges" /> </g> </svg>` const blob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) clearTimeout(this.downloadQrTimer) this.downloadedQr.set(true) this.downloadQr.emit({ url: text, filename }) this.downloadQrTimer = setTimeout(() => this.downloadedQr.set(false), 2000) } }src/app/components/blocks/utm-campaign-builder/index.ts0.1 kB
export { UiUtmCampaignBuilderComponent, CAMPAIGN_PRESETS, type UtmPreset, type UtmCampaignData, } from './utm-campaign-builder.component'
Raw manifest:https://uipkge.dev/r/angular/utm-campaign-builder.json