Menu

Change language

GitHub repo

Exit Intent — Survey

blockmarketing

One-question exit survey asking what the visitor did not find, with preset answers, an optional free-text field, and a thank-you state that closes itself.

Installation

$npx uipkge-ng@latest add exit-intent-survey
Manifest:https://uipkge.dev/r/angular/exit-intent-survey.jsonInstalls to:src/app/components/blocks/exit-intent-survey/

Variants

Loading interactive previews…

Files installed (2)

  • src/app/components/blocks/exit-intent-survey/exit-intent-survey.component.ts5.2 kB
    // One-question exit survey asking what the visitor did not find, with preset answers, an
    // optional free-text field, and a thank-you state that closes itself. Fires once per session
    // when the pointer leaves toward the browser chrome. Port of the Vue/React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, OnDestroy, OnInit, inject, signal } from '@angular/core'
    import { Check, LucideAngularModule } from 'lucide-angular'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    import { UiToggleGroupComponent, UiToggleGroupItemComponent } from '@/ui/toggle-group/toggle-group.component'
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-exit-intent-survey, [ui-exit-intent-survey]',
      standalone: true,
      // The block renders its own root <div> (and the dialog portals to body): keep the host out of layout.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiTextareaComponent,
        UiToggleGroupComponent,
        UiToggleGroupItemComponent,
      ],
      template: `
        <div data-slot="exit-intent-survey">
          <ui-dialog [open]="open()" (openChange)="open.set($event)">
            <ui-dialog-content class="sm:max-w-md">
              @if (!sent()) {
                <div>
                  <div ui-dialog-header>
                    <span ui-badge variant="secondary" class="w-fit">One question</span>
                    <h2 ui-dialog-title class="mt-3 text-xl leading-snug text-balance">What were you looking for?</h2>
                    <p ui-dialog-description>
                      It goes to the people who build the page. No follow-up email unless you ask for one.
                    </p>
                  </div>
    
                  <ui-toggle-group
                    type="single"
                    [value]="choice()"
                    variant="outline"
                    size="sm"
                    class="mt-5 flex-wrap justify-start"
                    aria-label="Reason for leaving"
                    (valueChange)="onChoiceChange($event)"
                  >
                    @for (option of options; track option) {
                      <button ui-toggle-group-item [value]="option">{{ option }}</button>
                    }
                  </ui-toggle-group>
    
                  <ui-textarea
                    [value]="detail()"
                    (valueChange)="detail.set($event)"
                    class="mt-3"
                    [rows]="3"
                    placeholder="Anything more (optional)"
                    label="Anything more (optional)"
                  />
    
                  <div class="mt-4 flex items-center gap-2">
                    <button ui-button [disabled]="!choice()" (click)="submit()">Send</button>
                    <button ui-button variant="ghost" (click)="open.set(false)">Skip</button>
                  </div>
                </div>
              } @else {
                <div class="py-6 text-center" aria-live="polite">
                  <span class="bg-success/10 mx-auto flex size-10 items-center justify-center rounded-full">
                    <lucide-icon [img]="Check" class="text-success size-5" aria-hidden="true" />
                  </span>
                  <p class="mt-3 text-sm font-medium">Thanks — that is genuinely useful.</p>
                </div>
              }
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiExitIntentSurveyComponent implements OnInit, OnDestroy {
      protected readonly Check = Check
    
      @Input() storageKey = 'uipkge:exit-survey-seen'
    
      readonly open = signal(false)
      readonly choice = signal('')
      readonly detail = signal('')
      readonly sent = signal(false)
    
      protected readonly options = [
        'Pricing was unclear',
        'Missing an integration',
        'Just browsing',
        'Security questions',
        'Something else',
      ]
    
      private sendTimer: ReturnType<typeof setTimeout> | undefined
    
      private readonly onPointerOut = (event: MouseEvent): void => {
        if (event.relatedTarget || event.clientY > 4) return
        try {
          if (window.sessionStorage.getItem(this.storageKey) === '1') return
          window.sessionStorage.setItem(this.storageKey, '1')
        } catch {
          // Blocked storage: shows once more this page view, never in a loop.
        }
        this.open.set(true)
        document.removeEventListener('mouseout', this.onPointerOut)
      }
    
      constructor() {
        inject(DestroyRef).onDestroy(() => clearTimeout(this.sendTimer))
      }
    
      ngOnInit(): void {
        document.addEventListener('mouseout', this.onPointerOut)
      }
    
      ngOnDestroy(): void {
        document.removeEventListener('mouseout', this.onPointerOut)
      }
    
      onChoiceChange(value: string): void {
        this.choice.set(value ?? '')
      }
    
      submit(): void {
        if (!this.choice()) return
        this.sent.set(true)
        // Close on its own rather than making someone dismiss a thank-you.
        clearTimeout(this.sendTimer)
        this.sendTimer = setTimeout(() => this.open.set(false), 1400)
      }
    }
    
  • src/app/components/blocks/exit-intent-survey/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/exit-intent-survey.json