Menu

Change language

GitHub repo

Email Template Previewer

blockmarketing

Resend and Klaviyo style marketing email designer preview with viewport switcher, subject line tester, deliverability & spam score analyzer, and live test dispatch modal.

Installation

$npx uipkge-ng@latest add email-template-previewer
Manifest:https://uipkge.dev/r/angular/email-template-previewer.jsonInstalls to:src/app/components/blocks/email-template-previewer/

Variants

Loading interactive previews…

Files installed (2)

  • src/app/components/blocks/email-template-previewer/email-template-previewer.component.ts39.8 kB
    // Resend and Klaviyo style marketing email designer preview with viewport switcher, subject
    // line tester, deliverability & spam score analyzer, and live test dispatch modal.
    // Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      ArrowRight,
      Check,
      CheckCircle2,
      Code2,
      Copy,
      FileCode,
      Flame,
      Inbox,
      Layers,
      Lock,
      LucideAngularModule,
      Mail,
      Monitor,
      Palette,
      Send,
      ShieldCheck,
      Smartphone,
      Terminal,
      Zap,
    } 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 {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export type PreviewerViewport = 'desktop' | 'mobile' | 'raw'
    export type PreviewerClient = 'all' | 'gmail' | 'apple' | 'outlook'
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-email-template-previewer, [ui-email-template-previewer]',
      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,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiInputComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="email-template-previewer" [class]="rootClass">
          <!-- Top Toolbar Card -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-header class="pb-4">
              <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
                <div class="space-y-1">
                  <div class="flex items-center gap-2">
                    <h3 ui-card-title class="text-lg font-semibold tracking-tight">Marketing Email Designer</h3>
                    <span ui-badge variant="outline" class="text-muted-foreground gap-1 border-dashed">
                      <lucide-icon [img]="Send" class="text-primary size-3" />
                      Resend Engine
                    </span>
                  </div>
                  <p ui-card-description class="text-xs">
                    Live viewport rendering, deliverability telemetry, and RFC-compliant HTML generator.
                  </p>
                </div>
    
                <div class="flex flex-wrap items-center gap-2">
                  <div
                    class="bg-muted/80 border-border inline-flex max-w-full items-center overflow-x-auto rounded-lg border p-1 shadow-xs"
                  >
                    <button
                      type="button"
                      ui-button
                      size="xs"
                      [variant]="viewport() === 'desktop' ? 'default' : 'ghost'"
                      class="gap-1.5 text-xs font-medium"
                      (click)="viewport.set('desktop')"
                    >
                      <lucide-icon [img]="Monitor" class="size-3.5" />
                      <span>Desktop 600px</span>
                    </button>
                    <button
                      type="button"
                      ui-button
                      size="xs"
                      [variant]="viewport() === 'mobile' ? 'default' : 'ghost'"
                      class="gap-1.5 text-xs font-medium"
                      (click)="viewport.set('mobile')"
                    >
                      <lucide-icon [img]="Smartphone" class="size-3.5" />
                      <span>Mobile 375px</span>
                    </button>
                    <button
                      type="button"
                      ui-button
                      size="xs"
                      [variant]="viewport() === 'raw' ? 'default' : 'ghost'"
                      class="gap-1.5 text-xs font-medium"
                      (click)="viewport.set('raw')"
                    >
                      <lucide-icon [img]="Code2" class="size-3.5" />
                      <span>Raw HTML</span>
                    </button>
                  </div>
    
                  <button ui-button variant="outline" size="sm" class="gap-1.5 text-xs" (click)="isTestModalOpen.set(true)">
                    <lucide-icon [img]="Send" class="text-primary size-3.5" />
                    <span>Send Test Email</span>
                  </button>
    
                  <button ui-button variant="default" size="sm" class="gap-1.5 text-xs" (click)="copyHtmlToClipboard()">
                    @if (isCopied()) {
                      <lucide-icon [img]="Check" class="text-success size-3.5" />
                    } @else {
                      <lucide-icon [img]="Copy" class="size-3.5" />
                    }
                    <span>{{ isCopied() ? 'Copied HTML!' : 'Export HTML' }}</span>
                  </button>
                </div>
              </div>
            </div>
    
            <div ui-separator></div>
    
            <div ui-card-content class="pt-4">
              <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
                <div class="space-y-1.5">
                  <div class="flex items-center justify-between text-xs">
                    <label class="text-foreground font-medium">Subject Line</label>
                    <div class="flex items-center gap-1.5">
                      <span ui-badge [variant]="subjectStatus().variant" class="h-5 px-1.5 text-xs font-normal">
                        {{ subjectCharCount() }}/60 chars
                      </span>
                      <span ui-badge variant="outline" class="text-muted-foreground h-5 gap-1 px-1.5 text-xs font-normal">
                        <lucide-icon [img]="Flame" class="text-warning size-3" />
                        <span>{{ emojiCount() }} emoji</span>
                      </span>
                    </div>
                  </div>
                  <ui-input
                    [value]="subject()"
                    (valueChange)="subject.set($event)"
                    class="text-xs"
                    placeholder="Enter campaign subject line..."
                  />
                </div>
    
                <div class="space-y-1.5">
                  <div class="flex items-center justify-between text-xs">
                    <label class="text-foreground font-medium">Preview Text / Preheader</label>
                    <span ui-badge variant="secondary" class="text-muted-foreground h-5 px-1.5 text-xs font-normal">
                      {{ preheaderCharCount() }}/90 chars
                    </span>
                  </div>
                  <ui-input
                    [value]="previewText()"
                    (valueChange)="previewText.set($event)"
                    class="text-xs"
                    placeholder="Secondary snippet shown in mail list..."
                  />
                </div>
              </div>
            </div>
          </div>
    
          <!-- 2-Column Preview Workspace -->
          <div class="grid grid-cols-1 items-start gap-6 lg:grid-cols-12">
            <div class="flex flex-col items-center lg:col-span-8">
              <div
                class="border-border bg-muted/30 relative flex min-h-[720px] w-full flex-col items-center justify-start overflow-hidden rounded-xl border p-4 sm:p-6"
              >
                @if (viewport() === 'raw') {
                  <div class="w-full space-y-3">
                    <div class="flex items-center justify-between">
                      <div class="flex items-center gap-2">
                        <lucide-icon [img]="FileCode" class="text-muted-foreground size-4" />
                        <span class="text-foreground font-mono text-xs font-semibold">template.compiled.html</span>
                        <span ui-badge variant="secondary" class="h-4.5 text-xs">14.2 KB</span>
                      </div>
                      <button ui-button variant="outline" size="xs" class="gap-1 text-xs" (click)="copyHtmlToClipboard()">
                        @if (isCopied()) {
                          <lucide-icon [img]="Check" class="text-success size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="size-3" />
                        }
                        <span>{{ isCopied() ? 'Copied' : 'Copy Source' }}</span>
                      </button>
                    </div>
                    <pre
                      class="border-border bg-muted/70 text-foreground max-h-[640px] overflow-auto rounded-lg border p-4 font-mono text-xs leading-relaxed"
                    ><code>{{ rawHtmlOutput() }}</code></pre>
                  </div>
                } @else {
                  <div [class]="previewCanvasClass">
                    <div class="border-border bg-card overflow-hidden rounded-xl border shadow-sm">
                      <div class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-2.5">
                        <div class="flex items-center gap-1.5">
                          <div class="bg-destructive/80 size-2.5 rounded-full"></div>
                          <div class="bg-warning/80 size-2.5 rounded-full"></div>
                          <div class="bg-success/80 size-2.5 rounded-full"></div>
                        </div>
                        <div class="text-muted-foreground flex items-center gap-1.5 font-mono text-xs">
                          <lucide-icon [img]="Inbox" class="size-3" />
                          <span>UIPKGE Inbox Preview · {{ viewport() === 'desktop' ? '600px' : '375px' }}</span>
                        </div>
                        <span
                          ui-badge
                          variant="outline"
                          class="border-border text-muted-foreground h-4.5 gap-1 px-1.5 text-xs font-normal"
                        >
                          <lucide-icon [img]="Lock" class="text-success size-2.5" />
                          TLS 1.3
                        </span>
                      </div>
    
                      <div class="border-border bg-card/60 space-y-2 border-b p-4 text-xs">
                        <div class="flex items-center justify-between">
                          <div class="flex items-center gap-2">
                            <div
                              class="bg-primary text-primary-foreground flex size-7 items-center justify-center rounded-full font-bold"
                            >
                              U
                            </div>
                            <div>
                              <div class="flex items-center gap-1.5">
                                <span class="text-foreground font-semibold">{{ senderName }}</span>
                                <span class="text-muted-foreground">&lt;{{ senderEmail }}&gt;</span>
                              </div>
                              <div class="text-muted-foreground text-xs">To: {{ recipientEmail }}</div>
                            </div>
                          </div>
                          <span class="text-muted-foreground text-xs">Aug 21, 2026, 10:00 AM</span>
                        </div>
    
                        <div class="pt-1">
                          <div class="text-foreground text-sm font-semibold tracking-tight">{{ subject() }}</div>
                          <div class="text-muted-foreground line-clamp-1 text-xs">{{ previewText() }}</div>
                        </div>
                      </div>
    
                      <div class="bg-card space-y-6 p-6 sm:p-8">
                        <div class="border-border flex items-center justify-between border-b pb-4">
                          <div class="flex items-center gap-2">
                            <div
                              class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
                            >
                              <lucide-icon [img]="Zap" class="size-3.5 fill-current" />
                            </div>
                            <span class="text-foreground text-sm font-bold tracking-tight">UIPKGE</span>
                          </div>
                          <span ui-badge variant="secondary" class="text-xs font-medium">Changelog #24</span>
                        </div>
    
                        <div class="space-y-3">
                          <span
                            ui-badge
                            variant="outline"
                            class="text-primary border-primary/30 text-xs font-semibold tracking-wider uppercase"
                          >
                            Major Release
                          </span>
                          <h1 class="text-foreground text-2xl leading-tight font-bold tracking-tight sm:text-3xl">
                            Ship polished UIs in minutes
                          </h1>
                          <p class="text-muted-foreground text-sm leading-relaxed">
                            We completely rebuilt the registry engine. Zero npm dependencies, 100% code ownership, and full
                            native support for Vue 3 and React with modern OKLCH tokens.
                          </p>
                        </div>
    
                        <div class="border-border bg-muted/40 overflow-hidden rounded-lg border">
                          <div
                            class="border-border bg-muted/60 flex items-center justify-between gap-2 border-b px-3.5 py-2"
                          >
                            <div class="text-muted-foreground flex min-w-0 items-center gap-1.5 font-mono text-xs">
                              <lucide-icon [img]="Terminal" class="text-primary size-3 shrink-0" />
                              <span class="truncate">components/ui/button.tsx</span>
                            </div>
                            <span class="text-success shrink-0 font-mono text-xs font-medium">TypeScript</span>
                          </div>
                          <div class="space-y-3 p-4">
                            <div
                              class="border-border bg-background text-muted-foreground rounded-md border p-2.5 font-mono text-xs"
                            >
                              <span class="text-primary font-semibold">$</span> npx shadcn add @uipkge/button
                            </div>
    
                            <div
                              class="bg-card border-border flex flex-wrap items-center justify-center gap-2 rounded-md border p-3"
                            >
                              <button ui-button size="xs" variant="default">Primary</button>
                              <button ui-button size="xs" variant="secondary">Secondary</button>
                              <button ui-button size="xs" variant="outline">Outline</button>
                              <button ui-button size="xs" variant="destructive">Delete</button>
                            </div>
                          </div>
                        </div>
    
                        <div class="flex flex-col items-center justify-center space-y-2 pt-2 text-center">
                          <button ui-button size="lg" class="w-full px-8 font-semibold shadow-xs sm:w-auto">
                            <span>Explore Components</span>
                            <lucide-icon [img]="ArrowRight" class="size-4" />
                          </button>
                          <p class="text-muted-foreground text-xs">
                            Free & open source · MIT License · 300+ Primitives & Blocks
                          </p>
                        </div>
    
                        <div ui-separator></div>
    
                        <div class="space-y-3.5">
                          <div class="flex items-start gap-3">
                            <div
                              class="bg-primary/10 text-primary mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full"
                            >
                              <lucide-icon [img]="Zap" class="size-3.5" />
                            </div>
                            <div class="space-y-0.5">
                              <h4 class="text-foreground text-xs font-semibold">Zero Dependency Footprint</h4>
                              <p class="text-muted-foreground text-xs leading-relaxed">
                                Every component lives directly in your codebase. No semver conflicts or registry lock-in.
                              </p>
                            </div>
                          </div>
    
                          <div class="flex items-start gap-3">
                            <div
                              class="bg-primary/10 text-primary mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full"
                            >
                              <lucide-icon [img]="Palette" class="size-3.5" />
                            </div>
                            <div class="space-y-0.5">
                              <h4 class="text-foreground text-xs font-semibold">OKLCH Dark Mode Tokens</h4>
                              <p class="text-muted-foreground text-xs leading-relaxed">
                                Calibrated light & dark surfaces with verified WCAG AA contrast and fluid spring physics.
                              </p>
                            </div>
                          </div>
    
                          <div class="flex items-start gap-3">
                            <div
                              class="bg-primary/10 text-primary mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full"
                            >
                              <lucide-icon [img]="Layers" class="size-3.5" />
                            </div>
                            <div class="space-y-0.5">
                              <h4 class="text-foreground text-xs font-semibold">Dual-Framework Registry</h4>
                              <p class="text-muted-foreground text-xs leading-relaxed">
                                First-class TypeScript support for both Vue 3 (Reka UI) and React (Radix UI).
                              </p>
                            </div>
                          </div>
                        </div>
    
                        <div ui-separator></div>
    
                        <div class="text-muted-foreground space-y-3 pt-2 text-center text-xs">
                          <div class="flex flex-wrap items-center justify-center gap-3 font-medium">
                            <span class="hover:text-foreground cursor-pointer transition-colors">Documentation</span>
                            <span>·</span>
                            <span class="hover:text-foreground cursor-pointer transition-colors">GitHub</span>
                            <span>·</span>
                            <span class="hover:text-foreground cursor-pointer transition-colors">Discord</span>
                            <span>·</span>
                            <span class="hover:text-foreground cursor-pointer transition-colors">Preferences</span>
                          </div>
                          <p class="text-xs">UIPKGE Inc. · 548 Market St, Suite 29314, San Francisco, CA 94104</p>
                          <p class="text-xs">
                            You received this email because you subscribed to UIPKGE updates.
                            <span class="hover:text-foreground cursor-pointer underline">Unsubscribe anytime</span>.
                          </p>
                        </div>
                      </div>
                    </div>
                  </div>
                }
              </div>
            </div>
    
            <!-- Right Column: Quality & Deliverability Inspector -->
            <div class="space-y-4 lg:col-span-4">
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center justify-between">
                    <h3 ui-card-title class="text-sm font-semibold tracking-tight">Spam Score Analyzer</h3>
                    <lucide-icon [img]="ShieldCheck" class="text-success size-4" />
                  </div>
                  <p ui-card-description class="text-xs">Predictive inbox delivery rating based on content heuristics.</p>
                </div>
                <div ui-card-content class="space-y-3.5">
                  <div class="border-success/20 bg-success/10 rounded-lg border p-3">
                    <div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
                      <span class="text-success text-2xl font-bold tracking-tight">0.4</span>
                      <span class="text-muted-foreground text-xs font-medium">/ 10.0 scale</span>
                    </div>
                    <div class="text-success mt-1.5 flex items-center gap-1.5 text-xs font-medium">
                      <lucide-icon [img]="CheckCircle2" class="size-3.5" />
                      <span>Excellent inbox probability</span>
                    </div>
                    <div class="bg-muted mt-2.5 h-1.5 w-full overflow-hidden rounded-full">
                      <div class="bg-success h-full w-[96%] rounded-full"></div>
                    </div>
                  </div>
    
                  <div class="space-y-2 text-xs">
                    <div class="flex items-center justify-between">
                      <span class="text-muted-foreground flex items-center gap-1.5">
                        <lucide-icon [img]="Check" class="text-success size-3.5" />
                        Spam trigger words
                      </span>
                      <span class="text-foreground font-medium">0 detected</span>
                    </div>
                    <div class="flex items-center justify-between">
                      <span class="text-muted-foreground flex items-center gap-1.5">
                        <lucide-icon [img]="Check" class="text-success size-3.5" />
                        Text-to-image ratio
                      </span>
                      <span class="text-foreground font-medium">92% text (Safe)</span>
                    </div>
                    <div class="flex items-center justify-between">
                      <span class="text-muted-foreground flex items-center gap-1.5">
                        <lucide-icon [img]="Check" class="text-success size-3.5" />
                        RFC 8058 Unsubscribe
                      </span>
                      <span class="text-foreground font-medium">Included</span>
                    </div>
                    <div class="flex items-center justify-between">
                      <span class="text-muted-foreground flex items-center gap-1.5">
                        <lucide-icon [img]="Check" class="text-success size-3.5" />
                        Valid HTTPS links
                      </span>
                      <span class="text-foreground font-medium">6 links checked</span>
                    </div>
                  </div>
                </div>
              </div>
    
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <h3 ui-card-title class="text-sm font-semibold tracking-tight">Domain Authentication</h3>
                  <p ui-card-description class="text-xs">Security headers verified against DNS records.</p>
                </div>
                <div ui-card-content class="space-y-2.5">
                  <div class="flex items-center justify-between text-xs">
                    <span class="text-muted-foreground">SPF Record</span>
                    <span ui-badge variant="success" class="h-5 px-2 text-xs">Pass</span>
                  </div>
                  <div class="flex items-center justify-between text-xs">
                    <span class="text-muted-foreground">DKIM Signature</span>
                    <span ui-badge variant="success" class="h-5 px-2 text-xs">Pass (2048-bit)</span>
                  </div>
                  <div class="flex items-center justify-between text-xs">
                    <span class="text-muted-foreground">DMARC Policy</span>
                    <span ui-badge variant="success" class="h-5 px-2 text-xs">Pass (p=reject)</span>
                  </div>
                  <div class="flex items-center justify-between text-xs">
                    <span class="text-muted-foreground">TLS Handshake</span>
                    <span ui-badge variant="outline" class="text-foreground h-5 px-2 text-xs">TLS 1.3 Active</span>
                  </div>
                </div>
              </div>
    
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <h3 ui-card-title class="text-sm font-semibold tracking-tight">Contrast & Dark Mode</h3>
                  <p ui-card-description class="text-xs">Client dark mode and WCAG contrast conformance.</p>
                </div>
                <div ui-card-content class="space-y-2.5 text-xs">
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground">Dark Mode Contrast</span>
                    <span ui-badge variant="success" class="h-5 px-2 text-xs">100% WCAG AA</span>
                  </div>
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground">Text Contrast Ratio</span>
                    <span class="text-foreground font-semibold">14.8:1 (AAA)</span>
                  </div>
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground">Plaintext Fallback</span>
                    <span class="text-foreground font-medium">Auto-generated</span>
                  </div>
                </div>
              </div>
    
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-3">
                  <h3 ui-card-title class="text-sm font-semibold tracking-tight">Email Telemetry</h3>
                  <p ui-card-description class="text-xs">Reading metrics and payload optimization.</p>
                </div>
                <div ui-card-content class="space-y-2.5 text-xs">
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground">Estimated Reading Time</span>
                    <span class="text-foreground font-medium">45 sec · 185 words</span>
                  </div>
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground">Payload Size</span>
                    <span class="text-foreground font-medium">14.2 KB (102 KB safe)</span>
                  </div>
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground">Inline Assets</span>
                    <span class="text-foreground font-medium">1 Vector SVG</span>
                  </div>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Send Test Dialog Modal -->
          <ui-dialog [open]="isTestModalOpen()" (openChange)="isTestModalOpen.set($event)">
            <ui-dialog-content class="sm:max-w-md">
              <div ui-dialog-header>
                <h2 ui-dialog-title class="flex items-center gap-2 text-base">
                  <lucide-icon [img]="Mail" class="text-primary size-4" />
                  Dispatch Test Preview
                </h2>
                <p ui-dialog-description class="text-xs">
                  Send a live test message to your inbox to inspect rendering across email clients.
                </p>
              </div>
    
              <div class="space-y-4 py-2">
                <div class="space-y-1.5">
                  <label class="text-muted-foreground text-xs font-medium">Recipient Address</label>
                  <ui-input
                    [value]="testTargetEmail()"
                    (valueChange)="testTargetEmail.set($event)"
                    type="email"
                    placeholder="you@company.com"
                    class="text-xs"
                    [disabled]="isSendingTest()"
                  />
                </div>
    
                <div class="space-y-1.5">
                  <label class="text-muted-foreground text-xs font-medium">Target Preview Profile</label>
                  <div class="grid grid-cols-2 gap-2">
                    <button
                      type="button"
                      ui-button
                      size="xs"
                      [variant]="selectedClient() === 'all' ? 'default' : 'outline'"
                      class="justify-start text-xs"
                      (click)="selectedClient.set('all')"
                    >
                      🌐 All Clients
                    </button>
                    <button
                      type="button"
                      ui-button
                      size="xs"
                      [variant]="selectedClient() === 'gmail' ? 'default' : 'outline'"
                      class="justify-start text-xs"
                      (click)="selectedClient.set('gmail')"
                    >
                      ✉️ Gmail (Web)
                    </button>
                    <button
                      type="button"
                      ui-button
                      size="xs"
                      [variant]="selectedClient() === 'apple' ? 'default' : 'outline'"
                      class="justify-start text-xs"
                      (click)="selectedClient.set('apple')"
                    >
                      🍏 Apple Mail (iOS)
                    </button>
                    <button
                      type="button"
                      ui-button
                      size="xs"
                      [variant]="selectedClient() === 'outlook' ? 'default' : 'outline'"
                      class="justify-start text-xs"
                      (click)="selectedClient.set('outlook')"
                    >
                      💼 Outlook 365
                    </button>
                  </div>
                </div>
    
                @if (testSentSuccess()) {
                  <div
                    class="border-success/30 bg-success/10 text-success flex items-center gap-2 rounded-md border p-2.5 text-xs font-medium"
                  >
                    <lucide-icon [img]="CheckCircle2" class="size-4 shrink-0" />
                    <span>Test email dispatched to {{ testTargetEmail() }}!</span>
                  </div>
                }
              </div>
    
              <div ui-dialog-footer class="gap-2 sm:gap-0">
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  [disabled]="isSendingTest()"
                  (click)="isTestModalOpen.set(false)"
                >
                  Cancel
                </button>
                <button ui-button size="sm" class="gap-1.5" [disabled]="isSendingTest()" (click)="handleSendTest()">
                  <lucide-icon [img]="Send" class="size-3.5" />
                  <span>{{ isSendingTest() ? 'Sending...' : 'Send Test' }}</span>
                </button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiEmailTemplatePreviewerComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Code2 = Code2
      protected readonly Copy = Copy
      protected readonly FileCode = FileCode
      protected readonly Flame = Flame
      protected readonly Inbox = Inbox
      protected readonly Layers = Layers
      protected readonly Lock = Lock
      protected readonly Mail = Mail
      protected readonly Monitor = Monitor
      protected readonly Palette = Palette
      protected readonly Send = Send
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Smartphone = Smartphone
      protected readonly Terminal = Terminal
      protected readonly Zap = Zap
    
      protected readonly senderName = 'UIPKGE Team'
      protected readonly senderEmail = 'updates@uipkge.dev'
      protected readonly recipientEmail = 'subscriber@example.com'
    
      @Input('class') className?: string
    
      readonly viewport = signal<PreviewerViewport>('desktop')
      readonly subject = signal('🚀 Introducing UIPKGE 2.0: The unbundled UI registry')
      readonly previewText = signal(
        'Explore 300+ production components with zero npm dependencies. Native Vue 3 & React code.',
      )
    
      readonly isTestModalOpen = signal(false)
      readonly testTargetEmail = signal('alex.developer@company.com')
      readonly selectedClient = signal<PreviewerClient>('all')
      readonly isSendingTest = signal(false)
      readonly testSentSuccess = signal(false)
      readonly isCopied = signal(false)
    
      private timers: ReturnType<typeof setTimeout>[] = []
    
      constructor() {
        inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t)))
      }
    
      readonly subjectCharCount = computed(() => this.subject().length)
      readonly preheaderCharCount = computed(() => this.previewText().length)
    
      readonly emojiCount = computed(() => {
        const matches = this.subject().match(/\p{Extended_Pictographic}/gu)
        return matches ? matches.length : 0
      })
    
      readonly subjectStatus = computed(() => {
        const count = this.subjectCharCount()
        if (count >= 30 && count <= 60) return { label: 'Optimal length', variant: 'success' as const }
        if (count < 30) return { label: 'Short subject', variant: 'info' as const }
        return { label: 'Long subject (may truncate)', variant: 'warning' as const }
      })
    
      readonly rawHtmlOutput = computed(() => {
        const subject = this.subject().replace(/"/g, '&quot;')
        const preview = this.previewText().replace(/"/g, '&quot;')
        return `<!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <title>${subject}</title>
      <!--[if mso]>
      <noscript>
        <xml>
          <o:OfficeDocumentSettings>
            <o:PixelsPerInch>96</o:PixelsPerInch>
          </o:OfficeDocumentSettings>
        </xml>
      </noscript>
      <![endif]-->
      <style>
        body { margin:0; padding:0; background-color:#09090b; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; color:#fafafa; }
        table { border-collapse:collapse; }
        .email-container { max-width:600px; margin:0 auto; background-color:#18181b; border:1px solid #27272a; border-radius:12px; overflow:hidden; }
        .cta-btn { display:inline-block; background-color:#ffffff; color:#09090b; font-weight:600; padding:12px 28px; border-radius:8px; text-decoration:none; }
      </style>
    </head>
    <body style="margin:0; padding:0; background-color:#09090b; color:#fafafa;">
      <!-- Preheader text snippet -->
      <div style="display:none; max-height:0px; overflow:hidden; font-size:1px; line-height:1px; color:#fff; opacity:0;">
        ${preview}
      </div>
    
      <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#09090b;">
        <tr>
          <td align="center" style="padding:40px 16px;">
            <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width:600px; background-color:#18181b; border:1px solid #27272a; border-radius:12px; overflow:hidden;">
              <!-- Header Logo Banner -->
              <tr>
                <td style="padding:24px 32px; border-bottom:1px solid #27272a;">
                  <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
                    <tr>
                      <td align="left" style="font-weight:700; font-size:18px; color:#ffffff; letter-spacing:-0.02em;">
                        ⚡ UIPKGE
                      </td>
                      <td align="right" style="font-size:12px; color:#a1a1aa;">
                        Changelog #24
                      </td>
                    </tr>
                  </table>
                </td>
              </tr>
    
              <!-- Hero Section -->
              <tr>
                <td style="padding:36px 32px 20px 32px;">
                  <span style="display:inline-block; padding:4px 10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; background-color:rgba(255,255,255,0.08); color:#a1a1aa; border-radius:9999px; margin-bottom:16px;">
                    Major Release
                  </span>
                  <h1 style="margin:0 0 16px 0; font-size:26px; font-weight:700; line-height:1.25; color:#ffffff; letter-spacing:-0.03em;">
                    Ship polished UIs in minutes
                  </h1>
                  <p style="margin:0 0 24px 0; font-size:15px; line-height:1.6; color:#a1a1aa;">
                    We completely rebuilt the registry engine. Zero npm dependencies, 100% code ownership, and full native support for Vue 3 and React with modern OKLCH tokens.
                  </p>
                </td>
              </tr>
    
              <!-- Code Snippet Card -->
              <tr>
                <td style="padding:0 32px 24px 32px;">
                  <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#09090b; border:1px solid #27272a; border-radius:8px;">
                    <tr>
                      <td style="padding:10px 16px; border-bottom:1px solid #27272a; font-family:monospace; font-size:11px; color:#71717a;">
                        Terminal &bull; Quick Install
                      </td>
                    </tr>
                    <tr>
                      <td style="padding:14px 16px; font-family:monospace; font-size:13px; color:#38bdf8;">
                        npx shadcn-vue add @uipkge/button
                      </td>
                    </tr>
                  </table>
                </td>
              </tr>
    
              <!-- Primary CTA Button -->
              <tr>
                <td align="center" style="padding:12px 32px 32px 32px;">
                  <table role="presentation" border="0" cellpadding="0" cellspacing="0">
                    <tr>
                      <td align="center" style="border-radius:8px; background-color:#ffffff;">
                        <a href="https://uipkge.dev" target="_blank" style="font-size:14px; font-weight:600; color:#09090b; text-decoration:none; padding:12px 32px; display:inline-block; border-radius:8px;">
                          Explore Components &rarr;
                        </a>
                      </td>
                    </tr>
                  </table>
                </td>
              </tr>
    
              <!-- Highlights List -->
              <tr>
                <td style="padding:24px 32px; border-top:1px solid #27272a; background-color:rgba(255,255,255,0.02);">
                  <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
                    <tr>
                      <td style="padding:8px 0;">
                        <strong style="color:#ffffff; font-size:14px;">⚡ Zero Dependency Footprint:</strong>
                        <span style="color:#a1a1aa; font-size:14px;"> Components live directly in your code.</span>
                      </td>
                    </tr>
                    <tr>
                      <td style="padding:8px 0;">
                        <strong style="color:#ffffff; font-size:14px;">🎨 OKLCH Dark Mode Tokens:</strong>
                        <span style="color:#a1a1aa; font-size:14px;"> Fluid WCAG AA contrast calibrated for all themes.</span>
                      </td>
                    </tr>
                    <tr>
                      <td style="padding:8px 0;">
                        <strong style="color:#ffffff; font-size:14px;">🧩 Dual-Framework Registry:</strong>
                        <span style="color:#a1a1aa; font-size:14px;"> Full TypeScript parity for Vue 3 and React.</span>
                      </td>
                    </tr>
                  </table>
                </td>
              </tr>
    
              <!-- Footer -->
              <tr>
                <td style="padding:28px 32px; border-top:1px solid #27272a; text-align:center;">
                  <p style="margin:0 0 10px 0; font-size:12px; color:#71717a;">
                    UIPKGE Inc. &bull; 548 Market St, Suite 29314, San Francisco, CA 94104
                  </p>
                  <p style="margin:0; font-size:12px; color:#71717a;">
                    <a href="https://uipkge.dev" style="color:#a1a1aa; text-decoration:underline;">Docs</a> &bull;
                    <a href="https://github.com/uipkge" style="color:#a1a1aa; text-decoration:underline;">GitHub</a> &bull;
                    <a href="#unsubscribe" style="color:#a1a1aa; text-decoration:underline;">Unsubscribe</a>
                  </p>
                </td>
              </tr>
            </table>
          </td>
        </tr>
      </table>
    </body>
    </html>`
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      get previewCanvasClass(): string {
        return cn(
          'w-full transition-[max-width] duration-300 ease-in-out',
          this.viewport() === 'desktop' ? 'max-w-[600px]' : 'max-w-[375px]',
        )
      }
    
      async copyHtmlToClipboard(): Promise<void> {
        try {
          await navigator.clipboard.writeText(this.rawHtmlOutput())
          this.isCopied.set(true)
          this.timers.push(
            setTimeout(() => {
              this.isCopied.set(false)
            }, 2000),
          )
        } catch {
          // Clipboard unavailable (jsdom): leave the button state untouched.
        }
      }
    
      handleSendTest(): void {
        if (!this.testTargetEmail()) return
        this.isSendingTest.set(true)
        this.testSentSuccess.set(false)
        this.timers.push(
          setTimeout(() => {
            this.isSendingTest.set(false)
            this.testSentSuccess.set(true)
            this.timers.push(
              setTimeout(() => {
                this.testSentSuccess.set(false)
                this.isTestModalOpen.set(false)
              }, 2000),
            )
          }, 700),
        )
      }
    }
    
  • src/app/components/blocks/email-template-previewer/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/email-template-previewer.json