Menu

Change language

GitHub repo

Utm Campaign Builder

blockmarketing

Google Analytics 4 (GA4) campaign link generator with preset presets, shortlink generator, syntax-highlighted URL preview, parameter verification table, and vector QR code export.

Installation

$npx uipkge-ng@latest add utm-campaign-builder
Manifest:https://uipkge.dev/r/angular/utm-campaign-builder.jsonInstalls to:src/app/components/blocks/utm-campaign-builder/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

UtmPreset
interface UtmPreset {
  id: string
  name: string
  description: string
  baseUrl: string
  source: string
  medium: string
  campaign: string
  term?: string
  content?: string
  shortlinkSlug: string
}
UtmCampaignData
interface UtmCampaignData {
  baseUrl: string
  source: string
  medium: string
  campaign: string
  term: string
  content: string
  fullUrl: string
  shortlinkUrl: string
}
QueryParamItem
interface 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">&amp;</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

Raw manifest:https://uipkge.dev/r/angular/utm-campaign-builder.json