Menu

Change language

GitHub repo

Doctor Appointment Scheduler

blockhealthcare

Zocdoc and Epic style specialist doctor appointment booking scheduler: physician profile hero card with rating, clinic address, and insurance badges, interactive visit type selector, reason for visit dropdown, date carousel with daily availability indicators, morning and afternoon time slot picker, live appointment summary readout with copay estimate, patient registration inputs, and booking confirmation flow.

Installation

$npx uipkge-ng@latest add doctor-appointment-scheduler
Manifest:https://uipkge.dev/r/angular/doctor-appointment-scheduler.jsonInstalls to:src/app/components/blocks/doctor-appointment-scheduler/

Variants

Loading interactive previews…

Schema

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

WeekDay
interface WeekDay {
  id: string
  weekday: string
  day: string
  month: string
  fullDate: string
  slotsCount: number
}
VisitReason
interface VisitReason {
  value: string
  label: string
  description: string
  copay: string
}

Files installed (2)

  • src/app/components/blocks/doctor-appointment-scheduler/doctor-appointment-scheduler.component.ts34.2 kB
    // Zocdoc and Epic style specialist doctor appointment booking scheduler: physician profile hero
    // card with rating, clinic address, and insurance badges, interactive visit type selector, reason
    // for visit dropdown, date carousel with daily availability indicators, morning and afternoon
    // time slot picker, live appointment summary readout with copay estimate, patient registration
    // inputs, and booking confirmation flow. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      BadgeCheck,
      Building2,
      Calendar,
      CalendarCheck,
      Check,
      CheckCircle2,
      ChevronLeft,
      ChevronRight,
      Clock,
      CreditCard,
      FileText,
      HeartPulse,
      Info,
      Lock,
      LucideAngularModule,
      MapPin,
      Phone,
      RotateCcw,
      ShieldCheck,
      Star,
      Stethoscope,
      Sun,
      Sunset,
      User,
      Video,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiRadioGroupComponent, UiRadioGroupItemComponent } from '@/ui/radio-group/radio-group.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export type SchedulerVisitType = 'in-person' | 'telehealth'
    
    interface WeekDay {
      id: string
      weekday: string
      day: string
      month: string
      fullDate: string
      slotsCount: number
    }
    
    const WEEK_DAYS: WeekDay[] = [
      { id: '2026-08-24', weekday: 'Mon', day: '24', month: 'Aug', fullDate: 'Monday, Aug 24, 2026', slotsCount: 6 },
      { id: '2026-08-25', weekday: 'Tue', day: '25', month: 'Aug', fullDate: 'Tuesday, Aug 25, 2026', slotsCount: 8 },
      { id: '2026-08-26', weekday: 'Wed', day: '26', month: 'Aug', fullDate: 'Wednesday, Aug 26, 2026', slotsCount: 5 },
      { id: '2026-08-27', weekday: 'Thu', day: '27', month: 'Aug', fullDate: 'Thursday, Aug 27, 2026', slotsCount: 8 },
      { id: '2026-08-28', weekday: 'Fri', day: '28', month: 'Aug', fullDate: 'Friday, Aug 28, 2026', slotsCount: 7 },
      { id: '2026-08-29', weekday: 'Sat', day: '29', month: 'Aug', fullDate: 'Saturday, Aug 29, 2026', slotsCount: 4 },
    ]
    
    const MORNING_SLOTS = ['09:00 AM', '09:30 AM', '10:15 AM', '11:00 AM']
    const AFTERNOON_SLOTS = ['02:00 PM', '02:45 PM', '03:30 PM', '04:15 PM']
    
    interface VisitReason {
      value: string
      label: string
      description: string
      copay: string
    }
    
    const VISIT_REASONS: VisitReason[] = [
      {
        value: 'annual-checkup',
        label: 'Annual Checkup',
        description: 'Routine wellness & cardiovascular health screening',
        copay: '$25.00',
      },
      {
        value: 'follow-up',
        label: 'Follow-up',
        description: 'Post-treatment evaluation & diagnostic review',
        copay: '$20.00',
      },
      {
        value: 'chest-pain',
        label: 'Chest Pain Consultation',
        description: 'Comprehensive symptom evaluation & urgent diagnostics',
        copay: '$30.00',
      },
      {
        value: 'medication-review',
        label: 'Medication Review',
        description: 'Prescription adjustment & therapeutic monitoring',
        copay: '$15.00',
      },
      {
        value: 'hypertension-evaluation',
        label: 'Hypertension Evaluation',
        description: 'Blood pressure assessment & ongoing care plan',
        copay: '$25.00',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-doctor-appointment-scheduler, [ui-doctor-appointment-scheduler]',
      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,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiRadioGroupComponent,
        UiRadioGroupItemComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="doctor-appointment-scheduler" [class]="rootClass">
          <!-- 1. Doctor Profile Hero Card -->
          <div ui-card class="border-border bg-card overflow-hidden shadow-xs">
            <div ui-card-content class="p-6 md:p-8">
              <div class="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
                <div class="flex flex-col items-start gap-5 sm:flex-row sm:items-center">
                  <div class="relative shrink-0">
                    <ui-avatar class="border-primary/20 bg-muted size-20 rounded-2xl border-2 shadow-xs md:size-24">
                      <ui-avatar-image [src]="doctorAvatar" [alt]="doctorName" class="rounded-2xl object-cover" />
                      <ui-avatar-fallback class="bg-primary/10 text-primary rounded-2xl text-lg font-bold">
                        MT
                      </ui-avatar-fallback>
                    </ui-avatar>
                    <div
                      class="border-success/30 bg-success/10 text-success absolute -right-1.5 -bottom-1.5 flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold backdrop-blur-sm"
                    >
                      <span class="bg-success size-1.5 animate-pulse rounded-full"></span>
                      <span>Available</span>
                    </div>
                  </div>
    
                  <div class="space-y-2">
                    <div class="flex flex-wrap items-center gap-2">
                      <h1 class="text-foreground text-xl font-bold tracking-tight md:text-2xl">{{ doctorName }}</h1>
                      <lucide-icon [img]="BadgeCheck" class="text-primary size-5 shrink-0" aria-label="Verified Doctor" />
                    </div>
    
                    <p class="text-muted-foreground text-xs font-normal md:text-sm">{{ doctorTitle }}</p>
    
                    <div class="flex flex-wrap items-center gap-2 pt-0.5">
                      <span ui-badge variant="secondary" [wrap]="true" class="text-xs font-medium">{{ specialty }}</span>
                      <span
                        ui-badge
                        variant="outline"
                        [wrap]="true"
                        class="text-muted-foreground border-border text-xs font-medium"
                      >
                        16+ Yrs Experience
                      </span>
                      <span
                        ui-badge
                        variant="outline"
                        [wrap]="true"
                        class="text-muted-foreground border-border text-xs font-medium"
                      >
                        Cedars-Sinai Affiliated
                      </span>
                    </div>
                  </div>
                </div>
    
                <div class="border-border/80 bg-muted/30 flex shrink-0 flex-col gap-2.5 rounded-xl border p-4 md:max-w-xs">
                  <div class="flex items-center justify-between gap-3">
                    <div class="text-foreground flex items-center gap-1.5">
                      <lucide-icon [img]="Star" class="fill-warning text-warning size-4 shrink-0" />
                      <span class="text-sm font-bold tabular-nums">{{ rating }}</span>
                      <span class="text-muted-foreground text-xs">({{ reviewCount }} reviews)</span>
                    </div>
                    <span class="text-success text-xs font-medium">Top Rated</span>
                  </div>
    
                  <div class="text-muted-foreground flex items-start gap-2 text-xs">
                    <lucide-icon [img]="MapPin" class="text-primary mt-0.5 size-3.5 shrink-0" />
                    <span class="line-clamp-2 leading-relaxed">{{ clinicAddress }}</span>
                  </div>
    
                  <div class="text-muted-foreground flex items-start gap-2 text-xs">
                    <lucide-icon [img]="ShieldCheck" class="text-success mt-0.5 size-3.5 shrink-0" />
                    <span class="line-clamp-2 leading-relaxed"> Accepts BlueCross, Aetna, UnitedHealthcare, Medicare </span>
                  </div>
                </div>
              </div>
            </div>
          </div>
    
          <!-- 2. Interactive Booking Flow (2-Column) -->
          <div class="grid grid-cols-1 items-start gap-6 lg:grid-cols-12">
            <div class="space-y-6 lg:col-span-7">
              <!-- Step 1: Visit Type Selection -->
              <div class="space-y-3">
                <div class="flex items-center gap-2">
                  <div
                    class="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full text-xs font-bold"
                  >
                    1
                  </div>
                  <h2 class="text-foreground text-sm font-semibold">Select Visit Type</h2>
                </div>
    
                <ui-radio-group
                  [value]="visitType()"
                  (valueChange)="setVisitType($event)"
                  class="grid grid-cols-1 gap-3 sm:grid-cols-2"
                >
                  <label for="visit-in-person-ng" [class]="visitCardClass('in-person')">
                    <ui-radio-group-item id="visit-in-person-ng" value="in-person" class="mt-0.5" />
                    <div class="min-w-0 flex-1 space-y-1">
                      <div class="flex items-center gap-1.5">
                        <lucide-icon [img]="Building2" class="text-primary size-4 shrink-0" />
                        <span class="text-foreground text-sm font-semibold">In-Person Visit</span>
                      </div>
                      <p class="text-muted-foreground text-xs leading-relaxed">
                        Cedars-Sinai Medical Plaza, Suite 400 · In-office diagnostics & exam
                      </p>
                    </div>
                  </label>
    
                  <label for="visit-telehealth-ng" [class]="visitCardClass('telehealth')">
                    <ui-radio-group-item id="visit-telehealth-ng" value="telehealth" class="mt-0.5" />
                    <div class="min-w-0 flex-1 space-y-1">
                      <div class="flex items-center gap-1.5">
                        <lucide-icon [img]="Video" class="text-primary size-4 shrink-0" />
                        <span class="text-foreground text-sm font-semibold">Video Telehealth</span>
                      </div>
                      <p class="text-muted-foreground text-xs leading-relaxed">
                        HD video consultation · HIPAA link sent via SMS & email
                      </p>
                    </div>
                  </label>
                </ui-radio-group>
              </div>
    
              <!-- Step 2: Reason for Visit -->
              <div class="space-y-3">
                <div class="flex items-center gap-2">
                  <div
                    class="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full text-xs font-bold"
                  >
                    2
                  </div>
                  <h2 class="text-foreground text-sm font-semibold">Reason for Visit</h2>
                </div>
    
                <ui-select [value]="selectedReason()" (valueChange)="selectedReason.set($event)">
                  <button ui-select-trigger class="bg-card border-border h-11 w-full rounded-xl px-4 text-sm font-medium">
                    <ui-select-value placeholder="Choose a clinical reason for consultation" />
                  </button>
                  <ui-select-content class="border-border bg-popover rounded-xl">
                    @for (reason of visitReasons; track reason.value) {
                      <ui-select-item [value]="reason.value" class="cursor-pointer px-3 py-2.5 text-sm">
                        <div class="flex flex-col gap-0.5">
                          <span class="text-foreground font-medium">{{ reason.label }}</span>
                          <span class="text-muted-foreground text-xs">{{ reason.description }}</span>
                        </div>
                      </ui-select-item>
                    }
                  </ui-select-content>
                </ui-select>
              </div>
    
              <!-- Step 3: Date Carousel / Week Selector -->
              <div class="space-y-3">
                <div class="flex items-center justify-between">
                  <div class="flex items-center gap-2">
                    <div
                      class="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full text-xs font-bold"
                    >
                      3
                    </div>
                    <h2 class="text-foreground text-sm font-semibold">Select Date</h2>
                  </div>
    
                  <div class="flex items-center gap-2">
                    <span class="text-muted-foreground text-xs font-medium">August 2026</span>
                    <div class="flex items-center gap-1">
                      <button
                        ui-button
                        variant="outline"
                        size="icon-sm"
                        class="border-border size-7 rounded-lg"
                        aria-label="Previous week"
                      >
                        <lucide-icon [img]="ChevronLeft" class="size-3.5" />
                      </button>
                      <button
                        ui-button
                        variant="outline"
                        size="icon-sm"
                        class="border-border size-7 rounded-lg"
                        aria-label="Next week"
                      >
                        <lucide-icon [img]="ChevronRight" class="size-3.5" />
                      </button>
                    </div>
                  </div>
                </div>
    
                <div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
                  @for (day of weekDays; track day.id) {
                    <button type="button" [class]="dayCardClass(day.id)" (click)="selectedDate.set(day.id)">
                      <span [class]="dayWeekdayClass(day.id)">{{ day.weekday }}</span>
                      <span class="my-0.5 text-xl font-bold tabular-nums">{{ day.day }}</span>
                      <span [class]="daySlotsClass(day.id)">{{ day.slotsCount }} slots</span>
                    </button>
                  }
                </div>
              </div>
    
              <!-- Step 4: Available Time Slots Grid -->
              <div class="space-y-4">
                <div class="flex items-center justify-between">
                  <div class="flex items-center gap-2">
                    <div
                      class="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full text-xs font-bold"
                    >
                      4
                    </div>
                    <h2 class="text-foreground text-sm font-semibold">Available Time Slots</h2>
                  </div>
                  <span class="text-muted-foreground text-xs tabular-nums">
                    8 slots on {{ currentDateObj().weekday }}, {{ currentDateObj().month }} {{ currentDateObj().day }}
                  </span>
                </div>
    
                <div class="border-border/70 bg-card space-y-2 rounded-xl border p-4">
                  <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase">
                    <lucide-icon [img]="Sun" class="text-warning size-3.5" />
                    <span>Morning Slots</span>
                  </div>
                  <div class="grid grid-cols-2 gap-2 pt-1 sm:grid-cols-4">
                    @for (slot of morningSlots; track slot) {
                      <button type="button" [class]="slotClass(slot)" (click)="selectedTime.set(slot)">
                        {{ slot }}
                      </button>
                    }
                  </div>
                </div>
    
                <div class="border-border/70 bg-card space-y-2 rounded-xl border p-4">
                  <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase">
                    <lucide-icon [img]="Sunset" class="size-3.5 text-orange-500" />
                    <span>Afternoon Slots</span>
                  </div>
                  <div class="grid grid-cols-2 gap-2 pt-1 sm:grid-cols-4">
                    @for (slot of afternoonSlots; track slot) {
                      <button type="button" [class]="slotClass(slot)" (click)="selectedTime.set(slot)">
                        {{ slot }}
                      </button>
                    }
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Right Column: Appointment Summary Card (5 Cols) -->
            <div class="lg:col-span-5">
              <div class="sticky top-6">
                @if (!isBooked()) {
                  <div ui-card class="border-border bg-card overflow-hidden shadow-xs">
                    <div ui-card-header class="border-border/70 bg-muted/20 border-b pb-3">
                      <div class="flex items-center justify-between">
                        <div class="space-y-0.5">
                          <h3 ui-card-title class="text-foreground text-base font-bold">Appointment Summary</h3>
                          <p ui-card-description class="text-muted-foreground text-xs">
                            Live schedule & patient registration
                          </p>
                        </div>
                        <span
                          ui-badge
                          variant="outline"
                          [wrap]="true"
                          class="border-success/30 bg-success/10 text-success gap-1.5 text-xs font-medium"
                        >
                          <span class="bg-success size-1.5 animate-pulse rounded-full"></span>
                          <span>Slot Held: 09:48</span>
                        </span>
                      </div>
                    </div>
    
                    <div ui-card-content class="space-y-4 p-5">
                      <div class="border-primary/25 bg-primary/5 space-y-2.5 rounded-xl border p-4">
                        <div class="flex items-start gap-2.5">
                          <lucide-icon [img]="CalendarCheck" class="text-primary mt-0.5 size-4 shrink-0" />
                          <div class="space-y-0.5">
                            <p class="text-muted-foreground text-xs font-medium">Selected Slot</p>
                            <p class="text-foreground text-sm font-bold tabular-nums">{{ selectedSlotFormatted() }}</p>
                          </div>
                        </div>
    
                        <div class="border-primary/15 flex items-start gap-2.5 border-t pt-1">
                          @if (visitType() === 'in-person') {
                            <lucide-icon [img]="Building2" class="text-primary mt-0.5 size-4 shrink-0" />
                          } @else {
                            <lucide-icon [img]="Video" class="text-primary mt-0.5 size-4 shrink-0" />
                          }
                          <div class="space-y-0.5">
                            <p class="text-muted-foreground text-xs font-medium">Location / Mode</p>
                            <p class="text-foreground text-xs font-medium">{{ locationText() }}</p>
                          </div>
                        </div>
    
                        <div class="border-primary/15 flex items-start gap-2.5 border-t pt-1">
                          <lucide-icon [img]="Stethoscope" class="text-primary mt-0.5 size-4 shrink-0" />
                          <div class="space-y-0.5">
                            <p class="text-muted-foreground text-xs font-medium">Visit Type</p>
                            <p class="text-foreground text-xs font-medium">{{ currentReasonObj().label }}</p>
                          </div>
                        </div>
                      </div>
    
                      <div class="border-border/80 bg-muted/40 flex items-center justify-between rounded-xl border p-3.5">
                        <div class="flex items-center gap-2">
                          <lucide-icon [img]="ShieldCheck" class="text-success size-4 shrink-0" />
                          <div class="space-y-0.5">
                            <p class="text-muted-foreground text-xs font-medium">Estimated Copay</p>
                            <p class="text-foreground text-xs font-semibold">BlueCross PPO In-Network</p>
                          </div>
                        </div>
                        <div class="text-right">
                          <span class="text-foreground text-base font-bold tabular-nums">{{
                            currentReasonObj().copay
                          }}</span>
                          <p class="text-muted-foreground text-xs">Covered at 90%</p>
                        </div>
                      </div>
    
                      <div ui-separator class="my-2"></div>
    
                      <div class="space-y-3">
                        <p class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                          Patient Information
                        </p>
    
                        <div class="space-y-1.5">
                          <label ui-label for="sched-patient-name-ng" class="text-foreground text-xs font-medium">
                            Full Name <span class="text-destructive">*</span>
                          </label>
                          <ui-input
                            id="sched-patient-name-ng"
                            [value]="patientName()"
                            (valueChange)="patientName.set($event)"
                            placeholder="e.g. Sarah Jenkins"
                            class="h-9 text-xs"
                          />
                        </div>
    
                        <div class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
                          <div class="space-y-1.5">
                            <label ui-label for="sched-patient-phone-ng" class="text-foreground text-xs font-medium">
                              Phone Number <span class="text-destructive">*</span>
                            </label>
                            <ui-input
                              id="sched-patient-phone-ng"
                              [value]="patientPhone()"
                              (valueChange)="patientPhone.set($event)"
                              placeholder="+1 (555) 382-9104"
                              class="h-9 text-xs tabular-nums"
                            />
                          </div>
    
                          <div class="space-y-1.5">
                            <label ui-label for="sched-insurance-ng" class="text-foreground text-xs font-medium">
                              Insurance Policy #
                            </label>
                            <ui-input
                              id="sched-insurance-ng"
                              [value]="insurancePolicy()"
                              (valueChange)="insurancePolicy.set($event)"
                              placeholder="BCBS-90481240"
                              class="h-9 font-mono text-xs uppercase"
                            />
                          </div>
                        </div>
    
                        <div class="space-y-1.5">
                          <label ui-label for="sched-notes-ng" class="text-foreground text-xs font-medium">
                            Clinical Symptoms / Notes
                          </label>
                          <ui-textarea
                            id="sched-notes-ng"
                            [value]="notes()"
                            (valueChange)="notes.set($event)"
                            [rows]="2"
                            placeholder="Briefly describe your symptoms or current medications..."
                            class="text-xs leading-relaxed"
                          />
                        </div>
                      </div>
                    </div>
    
                    <div ui-card-footer class="border-border/60 bg-muted/10 flex flex-col gap-3 border-t p-5 pt-0">
                      <button
                        ui-button
                        class="h-11 w-full rounded-xl text-sm font-semibold shadow-xs"
                        (click)="handleBook()"
                      >
                        <lucide-icon [img]="CalendarCheck" class="mr-2 size-4 shrink-0" />
                        <span>Confirm & Book Appointment</span>
                      </button>
    
                      <div class="text-muted-foreground flex items-center justify-center gap-1.5 text-xs">
                        <lucide-icon [img]="Lock" class="text-success size-3 shrink-0" />
                        <span>256-bit encrypted · HIPAA compliant · Free cancellation up to 24h</span>
                      </div>
                    </div>
                  </div>
                } @else {
                  <div ui-card class="border-success/30 bg-success/5 overflow-hidden shadow-xs">
                    <div ui-card-header class="pt-6 pb-3 text-center">
                      <div
                        class="border-success/30 bg-success/10 text-success ring-success/10 mx-auto mb-2 flex size-12 items-center justify-center rounded-full border ring-4"
                      >
                        <lucide-icon [img]="CheckCircle2" class="size-6" />
                      </div>
                      <h3 ui-card-title class="text-foreground text-lg font-bold">Appointment Confirmed!</h3>
                      <p ui-card-description class="text-muted-foreground text-xs">
                        Confirmation #{{ bookingReference }} dispatched to {{ patientPhone() }}
                      </p>
                    </div>
    
                    <div ui-card-content class="space-y-4 p-5">
                      <div class="border-border bg-card space-y-3 rounded-xl border p-4 text-xs">
                        <div class="border-border/80 flex items-center justify-between border-b pb-2">
                          <span class="text-muted-foreground">Physician</span>
                          <span class="text-foreground font-semibold">{{ doctorName }}</span>
                        </div>
                        <div class="border-border/80 flex items-center justify-between border-b pb-2">
                          <span class="text-muted-foreground">Date & Time</span>
                          <span class="text-foreground font-semibold tabular-nums">{{ selectedSlotFormatted() }}</span>
                        </div>
                        <div class="border-border/80 flex items-center justify-between border-b pb-2">
                          <span class="text-muted-foreground">Location</span>
                          <span class="text-foreground font-semibold">{{ locationText() }}</span>
                        </div>
                        <div class="border-border/80 flex items-center justify-between border-b pb-2">
                          <span class="text-muted-foreground">Visit Type</span>
                          <span class="text-foreground font-semibold">{{ currentReasonObj().label }}</span>
                        </div>
                        <div class="border-border/80 flex items-center justify-between border-b pb-2">
                          <span class="text-muted-foreground">Patient</span>
                          <span class="text-foreground font-semibold">{{ patientName() }}</span>
                        </div>
                        <div class="flex items-center justify-between">
                          <span class="text-muted-foreground">Est. Copay Due</span>
                          <span class="text-foreground font-bold tabular-nums">{{ currentReasonObj().copay }}</span>
                        </div>
                      </div>
    
                      <div
                        class="bg-muted/40 border-border/80 text-muted-foreground space-y-1 rounded-xl border p-3 text-xs"
                      >
                        <p class="text-foreground flex items-center gap-1.5 font-medium">
                          <lucide-icon [img]="Info" class="text-primary size-3.5 shrink-0" />
                          Pre-Appointment Instructions
                        </p>
                        <p class="leading-relaxed">
                          Please arrive 15 minutes early with your photo ID and active insurance card. For telehealth, join
                          link activates 10 minutes prior to visit.
                        </p>
                      </div>
                    </div>
    
                    <div ui-card-footer class="flex flex-col gap-2.5 p-5 pt-0">
                      <button
                        ui-button
                        variant="outline"
                        class="border-border h-10 w-full rounded-xl text-xs font-semibold"
                        [disabled]="calendarAdded()"
                        (click)="handleAddToCalendar()"
                      >
                        @if (calendarAdded()) {
                          <lucide-icon [img]="Check" class="text-success mr-1.5 size-3.5" />
                        } @else {
                          <lucide-icon [img]="Calendar" class="mr-1.5 size-3.5" />
                        }
                        <span>{{ calendarAdded() ? 'Added to Calendar' : 'Add to Google / Apple Calendar' }}</span>
                      </button>
    
                      <button
                        ui-button
                        variant="ghost"
                        class="text-muted-foreground hover:text-foreground h-9 w-full text-xs font-medium"
                        (click)="handleReset()"
                      >
                        <lucide-icon [img]="RotateCcw" class="mr-1.5 size-3" />
                        <span>Schedule Another Appointment</span>
                      </button>
                    </div>
                  </div>
                }
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiDoctorAppointmentSchedulerComponent {
      protected readonly BadgeCheck = BadgeCheck
      protected readonly Building2 = Building2
      protected readonly Calendar = Calendar
      protected readonly CalendarCheck = CalendarCheck
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly ChevronLeft = ChevronLeft
      protected readonly ChevronRight = ChevronRight
      protected readonly Info = Info
      protected readonly Lock = Lock
      protected readonly MapPin = MapPin
      protected readonly RotateCcw = RotateCcw
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Star = Star
      protected readonly Stethoscope = Stethoscope
      protected readonly Sun = Sun
      protected readonly Sunset = Sunset
      protected readonly Video = Video
    
      protected readonly weekDays = WEEK_DAYS
      protected readonly morningSlots = MORNING_SLOTS
      protected readonly afternoonSlots = AFTERNOON_SLOTS
      protected readonly visitReasons = VISIT_REASONS
      protected readonly bookingReference = 'APT-84920'
    
      // react/no-unused-vars parity: these icons back copy kept 1:1 with the React block.
      protected readonly Clock = Clock
      protected readonly CreditCard = CreditCard
      protected readonly FileText = FileText
      protected readonly HeartPulse = HeartPulse
      protected readonly Phone = Phone
      protected readonly User = User
    
      @Input() doctorName = 'Dr. Marcus Thorne, MD, FACC'
      @Input() doctorTitle = 'Board-Certified Cardiologist · Cedars-Sinai Heart Institute'
      @Input() doctorAvatar = 'https://images.unsplash.com/photo-1622253692010-333f2da6031d?q=80&w=320&auto=format&fit=crop'
      @Input() specialty = 'Cardiology & Internal Medicine'
      @Input() clinicName = 'Cedars-Sinai Medical Plaza'
      @Input() clinicAddress = 'Cedars-Sinai Medical Plaza, Suite 400, Los Angeles, CA'
      @Input() rating = 4.9
      @Input() reviewCount = 240
      @Input() insurances: string[] = ['BlueCross BlueShield', 'Aetna', 'UnitedHealthcare', 'Medicare', 'Cigna']
      @Input() set initialDate(v: string) {
        this.selectedDate.set(v)
      }
      @Input() set initialTime(v: string) {
        this.selectedTime.set(v)
      }
      @Input() set initialVisitType(v: SchedulerVisitType) {
        this.visitType.set(v)
      }
      @Input() set initialReason(v: string) {
        this.selectedReason.set(v)
      }
      @Input('class') className?: string
    
      readonly selectedDate = signal('2026-08-27')
      readonly selectedTime = signal('10:15 AM')
      readonly visitType = signal<SchedulerVisitType>('in-person')
      readonly selectedReason = signal('chest-pain')
      readonly patientName = signal('Sarah Jenkins')
      readonly patientPhone = signal('+1 (555) 382-9104')
      readonly insurancePolicy = signal('BCBS-90481240')
      readonly notes = signal('Occasional mild tightness after morning workouts; seeking ECG review.')
      readonly isBooked = signal(false)
      readonly calendarAdded = signal(false)
    
      readonly currentDateObj = computed(() => WEEK_DAYS.find((d) => d.id === this.selectedDate()) ?? WEEK_DAYS[3]!)
      readonly currentReasonObj = computed(
        () => VISIT_REASONS.find((r) => r.value === this.selectedReason()) ?? VISIT_REASONS[2]!,
      )
      readonly selectedSlotFormatted = computed(() => {
        const d = this.currentDateObj()
        return `${d.weekday}, ${d.month} ${d.day} at ${this.selectedTime()}`
      })
      readonly locationText = computed(() =>
        this.visitType() === 'in-person'
          ? 'In-Person · Room 402, Cedars-Sinai Plaza'
          : 'Video Telehealth · Secure HIPAA Link',
      )
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-6xl space-y-6', this.className)
      }
    
      setVisitType(value: string): void {
        this.visitType.set(value as SchedulerVisitType)
      }
    
      visitCardClass(kind: SchedulerVisitType): string {
        return cn(
          'flex cursor-pointer items-start gap-3.5 rounded-xl border p-4 transition-colors duration-150',
          this.visitType() === kind
            ? 'border-primary bg-primary/5 ring-primary/30 shadow-xs ring-1'
            : 'border-border bg-card hover:bg-muted/40',
        )
      }
    
      dayCardClass(id: string): string {
        const selected = this.selectedDate() === id
        return cn(
          'focus-visible:ring-primary flex cursor-pointer flex-col items-center justify-center rounded-xl border p-3 text-center transition-colors duration-150 outline-none focus-visible:ring-2',
          selected
            ? 'border-primary bg-primary text-primary-foreground ring-primary/20 font-semibold shadow-xs ring-2'
            : 'border-border bg-card hover:bg-muted/50 text-foreground',
        )
      }
    
      dayWeekdayClass(id: string): string {
        const selected = this.selectedDate() === id
        return cn(
          'text-xs font-medium tracking-wider uppercase',
          selected ? 'text-primary-foreground/90' : 'text-muted-foreground',
        )
      }
    
      daySlotsClass(id: string): string {
        const selected = this.selectedDate() === id
        return cn('text-xs font-medium', selected ? 'text-primary-foreground/80' : 'text-success')
      }
    
      slotClass(slot: string): string {
        const selected = this.selectedTime() === slot
        return cn(
          'focus-visible:ring-primary flex cursor-pointer items-center justify-center rounded-lg border px-3 py-2.5 text-sm font-medium tabular-nums transition-colors duration-150 outline-none focus-visible:ring-2',
          selected
            ? 'border-primary bg-primary text-primary-foreground ring-primary/30 font-semibold shadow-xs ring-2'
            : 'border-border bg-background hover:bg-muted/60 text-foreground',
        )
      }
    
      handleBook(): void {
        if (!this.patientName().trim() || !this.patientPhone().trim()) return
        this.isBooked.set(true)
      }
    
      handleReset(): void {
        this.isBooked.set(false)
        this.calendarAdded.set(false)
      }
    
      handleAddToCalendar(): void {
        this.calendarAdded.set(true)
      }
    }
    
  • src/app/components/blocks/doctor-appointment-scheduler/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/doctor-appointment-scheduler.json