Menu

Change language

GitHub repo

Returns Portal

blockcommerce

Self-serve e-commerce returns & exchange portal wizard: Stepper header (Find Order → Select Items → Choose Resolution → Confirm & Print Label), order lookup with email and order number, item selection list with return reasons and quantities, resolution selector (size exchange, store credit with +10% bonus, or original payment refund), and confirmation screen with printable prepaid shipping label, QR code drop-off instructions, and return summary.

Installation

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

Variants

Loading interactive previews…

Schema

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

OrderItem
interface OrderItem {
  id: string
  name: string
  variant: string
  sku: string
  price: number
  maxQty: number
}

Files installed (2)

  • src/app/components/blocks/returns-portal/returns-portal.component.ts33.9 kB
    import {
      Component,
      Input,
      OnChanges,
      OnInit,
      SimpleChanges,
      computed,
      signal,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import {
      Check,
      CheckCircle2,
      ChevronLeft,
      ChevronRight,
      CreditCard,
      Download,
      Gift,
      LucideAngularModule,
      Printer,
      RefreshCw,
      Shirt,
      ShieldCheck,
      Truck,
    } 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,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiCheckboxComponent } from '@/ui/checkbox/checkbox.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 { UiStepperComponent, type StepperStep } from '@/ui/stepper/stepper.component'
    
    export interface OrderItem {
      id: string
      name: string
      variant: string
      sku: string
      price: number
      maxQty: number
    }
    
    export type ReturnsPortalResolution = 'exchange' | 'store-credit' | 'original-payment'
    
    const steps: StepperStep[] = [
      { id: 1, title: 'Find Order' },
      { id: 2, title: 'Select Items' },
      { id: 3, title: 'Choose Resolution' },
      { id: 4, title: 'Confirm & Print' },
    ]
    
    const orderItems: OrderItem[] = [
      {
        id: 'item-1',
        name: 'Merino Wool Crewneck Sweater',
        variant: 'Midnight Navy / Size L',
        sku: 'MWC-NAV-L',
        price: 120,
        maxQty: 1,
      },
      {
        id: 'item-2',
        name: 'Classic Canvas High-Tops',
        variant: 'Off-White / US 10.5',
        sku: 'CCHT-OW-105',
        price: 85,
        maxQty: 1,
      },
      {
        id: 'item-3',
        name: 'Tailored Chino Trousers',
        variant: 'Olive / 32x32',
        sku: 'TCT-OLV-32',
        price: 95,
        maxQty: 2,
      },
    ]
    
    const returnReasonsList = ['Wrong size', 'Item defective', 'Not as described', 'Changed mind', 'Arrived late']
    
    const exchangeSizesList = ['Small (S)', 'Medium (M)', 'Large (L)', 'Extra Large (XL)']
    
    const defaultSelectedItems = (): Record<string, boolean> => ({
      'item-1': true,
      'item-2': false,
      'item-3': false,
    })
    
    const defaultReturnQuantities = (): Record<string, number> => ({
      'item-1': 1,
      'item-2': 1,
      'item-3': 1,
    })
    
    const defaultReturnReasons = (): Record<string, string> => ({
      'item-1': 'Wrong size',
      'item-2': 'Changed mind',
      'item-3': 'Item defective',
    })
    
    const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
    
    const OPTION_BASE = 'flex cursor-pointer flex-col gap-3 rounded-lg border p-4 transition-colors'
    const OPTION_ACTIVE = 'border-primary bg-primary/[0.02] dark:bg-primary/10 shadow-xs'
    const OPTION_IDLE = 'border-border hover:bg-muted/40'
    
    /**
     * Angular port of the React ReturnsPortal block: a Card wizard with a four-step Stepper
     * (Find Order, Select Items, Choose Resolution, Confirm & Print), an order lookup form, item
     * selection with per-item quantity and reason Selects, a resolution RadioGroup (exchange,
     * store credit with a +10% bonus, original payment) with a live summary, and a confirmation
     * screen with label download, drop-off steps and a QR pass. Backward navigation through the
     * stepper is free until the return is confirmed; forward goes through the footer button only.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-returns-portal, [ui-returns-portal]',
      standalone: true,
      // React renders the Card itself: the host stays out of layout and `class` goes to the card.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiCheckboxComponent,
        UiInputComponent,
        UiRadioGroupComponent,
        UiRadioGroupItemComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiStepperComponent,
      ],
      template: `
        <div ui-card data-slot="returns-portal" [class]="cardClass">
          <div ui-card-header class="pb-4">
            <div class="flex items-center justify-between gap-4">
              <div>
                <h3 ui-card-title class="text-base font-semibold">Returns &amp; Exchange Portal</h3>
                <p ui-card-description class="text-xs">Self-serve return, exchange, or refund for your recent order.</p>
              </div>
              <span ui-badge variant="outline" class="shrink-0 text-xs">
                <lucide-icon [img]="Truck" [class]="'text-muted-foreground mr-1 size-3'" />
                Prepaid Shipping
              </span>
            </div>
          </div>
    
          <div ui-card-content class="space-y-6">
            <ui-stepper [steps]="steps" [value]="step()" (valueChange)="onStepperInput($event)" class="mb-6" />
    
            <!-- Step 1: Find Order -->
            @if (step() === 1) {
              <div class="space-y-5">
                <div class="space-y-1">
                  <h3 class="text-foreground text-sm font-semibold">Find your order</h3>
                  <p class="text-muted-foreground text-xs">
                    Enter your order number and email address to start a return or exchange.
                  </p>
                </div>
    
                <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
                  <div class="space-y-1.5">
                    <label for="returns-order-number" class="text-foreground text-xs font-medium">Order number</label>
                    <ui-input
                      id="returns-order-number"
                      [value]="orderNumber()"
                      (valueChange)="orderNumber.set($event)"
                      placeholder="#ORD-84920"
                      autoComplete="off"
                    />
                    <p class="text-muted-foreground text-xs">Found on your order confirmation email.</p>
                  </div>
    
                  <div class="space-y-1.5">
                    <label for="returns-email" class="text-foreground text-xs font-medium">Email address</label>
                    <ui-input
                      id="returns-email"
                      [value]="email()"
                      (valueChange)="email.set($event)"
                      type="email"
                      placeholder="sarah.connor@example.com"
                      autoComplete="email"
                    />
                    <p class="text-muted-foreground text-xs">The email address used at checkout.</p>
                  </div>
                </div>
    
                <div class="border-border bg-muted/40 flex items-start gap-3 rounded-lg border p-3.5">
                  <lucide-icon [img]="ShieldCheck" [class]="'text-primary mt-0.5 size-4 shrink-0'" />
                  <div class="space-y-0.5 text-xs">
                    <p class="text-foreground font-medium">30-Day Hassle-Free Return Policy</p>
                    <p class="text-muted-foreground">
                      Eligible items can be returned within 30 days of delivery. Free shipping on all exchanges and store
                      credit requests.
                    </p>
                  </div>
                </div>
              </div>
            }
    
            <!-- Step 2: Select Items -->
            @if (step() === 2) {
              <div class="space-y-5">
                <div class="flex flex-wrap items-center justify-between gap-2">
                  <div class="min-w-[12rem]">
                    <h3 class="text-foreground text-sm font-semibold">Select items to return</h3>
                    <p class="text-muted-foreground text-xs">
                      Order {{ orderNumber() || '#ORD-84920' }} • Placed Oct 14, 2026 • 3 items eligible
                    </p>
                  </div>
                  <span ui-badge variant="secondary" class="text-xs">{{ selectedItemsCount() }} of 3 selected</span>
                </div>
    
                <div class="space-y-3">
                  @for (item of orderItems; track item.id) {
                    <div [class]="itemClass(item.id)">
                      <div class="flex items-start gap-3.5">
                        <div class="pt-0.5">
                          <ui-checkbox
                            [id]="'check-' + item.id"
                            [checked]="isSelected(item.id)"
                            (checkedChange)="toggleItemSelection(item.id)"
                          />
                        </div>
    
                        <div
                          class="border-border bg-muted/60 text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-md border"
                        >
                          <lucide-icon [img]="Shirt" class="size-6" />
                        </div>
    
                        <div class="min-w-0 flex-1">
                          <div class="flex items-start justify-between gap-2">
                            <div>
                              <label
                                [attr.for]="'check-' + item.id"
                                class="text-foreground cursor-pointer text-sm font-medium select-none"
                                >{{ item.name }}</label
                              >
                              <p class="text-muted-foreground text-xs">{{ item.variant }} • SKU: {{ item.sku }}</p>
                            </div>
                            <span class="text-foreground shrink-0 text-sm font-semibold">{{
                              formatCurrency(item.price)
                            }}</span>
                          </div>
                        </div>
                      </div>
    
                      @if (isSelected(item.id)) {
                        <div class="border-border/60 mt-3.5 grid grid-cols-1 gap-3 border-t pt-3.5 sm:grid-cols-2">
                          <div class="space-y-1.5">
                            <label class="text-foreground text-xs font-medium">Qty to return</label>
                            <ui-select [value]="quantityValue(item.id)" (valueChange)="setQuantity(item.id, $event)">
                              <button ui-select-trigger class="h-8 w-full text-xs">
                                <ui-select-value placeholder="Qty" />
                              </button>
                              <ui-select-content>
                                @for (q of quantityOptions(item.maxQty); track q) {
                                  <ui-select-item [value]="'' + q"
                                    >{{ q }} {{ q === 1 ? 'item' : 'items' }} (of {{ item.maxQty }})</ui-select-item
                                  >
                                }
                              </ui-select-content>
                            </ui-select>
                          </div>
    
                          <div class="space-y-1.5">
                            <label class="text-foreground text-xs font-medium">Return reason</label>
                            <ui-select [value]="reasonFor(item.id)" (valueChange)="setReason(item.id, $event)">
                              <button ui-select-trigger class="h-8 w-full text-xs">
                                <ui-select-value placeholder="Select reason" />
                              </button>
                              <ui-select-content>
                                @for (reason of returnReasonsList; track reason) {
                                  <ui-select-item [value]="reason">{{ reason }}</ui-select-item>
                                }
                              </ui-select-content>
                            </ui-select>
                          </div>
                        </div>
                      }
                    </div>
                  }
                </div>
    
                <div class="border-border bg-muted/40 flex items-center justify-between rounded-lg border p-3 text-xs">
                  <span class="text-muted-foreground">
                    @if (selectedItemsCount() > 0) {
                      <span class="text-foreground font-medium">{{ selectedItemsCount() }}</span> item{{
                        selectedItemsCount() === 1 ? '' : 's'
                      }}
                      selected
                    } @else {
                      No items selected yet
                    }
                  </span>
                  <span class="text-foreground font-medium">Estimated Value: {{ formatCurrency(itemsSubtotal()) }}</span>
                </div>
              </div>
            }
    
            <!-- Step 3: Choose Resolution -->
            @if (step() === 3) {
              <div class="space-y-5">
                <div class="space-y-1">
                  <h3 class="text-foreground text-sm font-semibold">Choose return resolution</h3>
                  <p class="text-muted-foreground text-xs">Select how you would like your return or refund handled.</p>
                </div>
    
                <ui-radio-group [value]="resolution()" (valueChange)="setResolution($event)" class="gap-3">
                  <!-- Option A: Exchange -->
                  <div [class]="optionClass('exchange')" (click)="resolution.set('exchange')">
                    <div class="flex items-start gap-3">
                      <ui-radio-group-item id="res-exchange" value="exchange" class="mt-0.5" />
                      <div class="flex-1 space-y-1">
                        <div class="flex items-center gap-2">
                          <!-- The React svg is a shrinkable flex item of this row (no shrink-0): a display:contents
                               host makes the svg the flex item here too, so narrow widths wrap the label the same way. -->
                          <lucide-icon [img]="RefreshCw" [class]="'text-primary size-4'" />
                          <label for="res-exchange" class="text-foreground cursor-pointer text-sm font-medium">
                            Exchange for different size / color
                          </label>
                          <span ui-badge variant="outline" class="text-xs">Free shipping</span>
                        </div>
                        <p class="text-muted-foreground text-xs">
                          Reserve replacement item immediately. Ships as soon as carrier scans the return package.
                        </p>
                      </div>
                    </div>
    
                    @if (resolution() === 'exchange') {
                      <div class="border-border/60 mt-1 space-y-1.5 border-t pt-3 pl-7" (click)="$event.stopPropagation()">
                        <label class="text-foreground text-xs font-medium">Select replacement size</label>
                        <ui-select [value]="exchangeSize()" (valueChange)="exchangeSize.set($event)">
                          <button ui-select-trigger class="h-8 w-full max-w-xs text-xs">
                            <ui-select-value placeholder="Select replacement size" />
                          </button>
                          <ui-select-content>
                            @for (size of exchangeSizesList; track size) {
                              <ui-select-item [value]="size">{{ size }}</ui-select-item>
                            }
                          </ui-select-content>
                        </ui-select>
                      </div>
                    }
                  </div>
    
                  <!-- Option B: Store Credit (+10% Bonus) -->
                  <div [class]="optionClass('store-credit')" (click)="resolution.set('store-credit')">
                    <div class="flex items-start gap-3">
                      <ui-radio-group-item id="res-store-credit" value="store-credit" class="mt-0.5" />
                      <div class="flex-1 space-y-1">
                        <div class="flex flex-wrap items-center gap-2">
                          <lucide-icon [img]="Gift" class="text-success size-4" />
                          <label for="res-store-credit" class="text-foreground cursor-pointer text-sm font-medium">
                            Store Credit (+10% Bonus value)
                          </label>
                          <span ui-badge variant="success" class="text-xs">+10% Bonus</span>
                          <span ui-badge variant="secondary" class="text-xs">Fastest refund</span>
                        </div>
                        <p class="text-muted-foreground text-xs">
                          Get <span class="text-foreground font-medium">{{ formatCurrency(totalStoreCredit()) }}</span> in
                          store credit ({{ formatCurrency(itemsSubtotal()) }} + {{ formatCurrency(storeCreditBonus()) }}
                          bonus). Digital card delivered by email instantly upon drop-off scan.
                        </p>
                      </div>
                    </div>
                  </div>
    
                  <!-- Option C: Original Payment Method -->
                  <div [class]="optionClass('original-payment')" (click)="resolution.set('original-payment')">
                    <div class="flex items-start gap-3">
                      <ui-radio-group-item id="res-original" value="original-payment" class="mt-0.5" />
                      <div class="flex-1 space-y-1">
                        <div class="flex flex-wrap items-center gap-2">
                          <lucide-icon [img]="CreditCard" class="text-muted-foreground size-4" />
                          <label for="res-original" class="text-foreground cursor-pointer text-sm font-medium">
                            Original Payment Method
                          </label>
                          <span ui-badge variant="outline" class="text-xs">Visa •••• 4242</span>
                        </div>
                        <p class="text-muted-foreground text-xs">
                          Refund of {{ formatCurrency(itemsSubtotal()) }} back to your card. Processed in 3–5 business days
                          after carrier drop-off.
                        </p>
                      </div>
                    </div>
                  </div>
                </ui-radio-group>
    
                <!-- Summary calculation box -->
                <div class="border-border bg-muted/40 space-y-2 rounded-lg border p-4 text-xs">
                  <div class="text-muted-foreground flex items-center justify-between">
                    <span>Selected items subtotal</span>
                    <span class="text-foreground font-medium">{{ formatCurrency(itemsSubtotal()) }}</span>
                  </div>
                  @if (resolution() === 'store-credit') {
                    <div class="text-success flex items-center justify-between">
                      <span>Bonus store credit (+10%)</span>
                      <span class="font-medium">+{{ formatCurrency(storeCreditBonus()) }}</span>
                    </div>
                  }
                  <div class="text-muted-foreground flex items-center justify-between">
                    <span>Prepaid return shipping</span>
                    <span class="text-success font-medium">FREE</span>
                  </div>
                  <div
                    class="border-border text-foreground flex items-center justify-between border-t pt-2 text-sm font-semibold"
                  >
                    <span>{{ totalLabel() }}</span>
                    <span [class]="resolution() === 'store-credit' ? 'text-success' : ''">{{ totalValue() }}</span>
                  </div>
                </div>
              </div>
            }
    
            <!-- Step 4: Confirmation & Label -->
            @if (step() === 4) {
              <div class="space-y-6">
                <div class="space-y-2 py-2 text-center">
                  <div
                    class="border-success/20 bg-success/10 text-success relative mx-auto flex size-12 items-center justify-center rounded-full border"
                  >
                    <lucide-icon [img]="CheckCircle2" class="size-6" />
                  </div>
                  <h3 class="text-foreground text-base font-semibold">Return Authorized &amp; Confirmed</h3>
                  <p class="text-muted-foreground mx-auto max-w-md text-xs">
                    Your return request has been submitted. A prepaid shipping label and confirmation receipt have been sent
                    to <span class="text-foreground font-medium">{{ email() || 'sarah.connor@example.com' }}</span
                    >.
                  </p>
                </div>
    
                <!-- Return Details Overview -->
                <div class="border-border bg-muted/20 space-y-3 rounded-lg border p-4">
                  <div class="border-border flex flex-wrap items-center justify-between gap-2 border-b pb-3">
                    <div class="space-y-0.5">
                      <span class="text-muted-foreground text-xs">Return ID</span>
                      <p class="text-foreground font-mono text-sm font-semibold">#RET-2026-849</p>
                    </div>
                    <span ui-badge variant="success" class="gap-1 text-xs">
                      <lucide-icon [img]="Check" class="size-3" /> Authorized
                    </span>
                  </div>
    
                  <div class="grid grid-cols-1 gap-3 text-xs sm:grid-cols-3">
                    <div class="space-y-0.5">
                      <span class="text-muted-foreground">Carrier</span>
                      <p class="text-foreground font-medium">USPS Ground Advantage™</p>
                    </div>
                    <div class="space-y-0.5">
                      <span class="text-muted-foreground">Tracking Number</span>
                      <p class="text-foreground font-mono font-medium">9400 1118 9956 2849</p>
                    </div>
                    <div class="space-y-0.5">
                      <span class="text-muted-foreground">Selected Resolution</span>
                      <p class="text-foreground font-medium">{{ resolutionSummary() }}</p>
                    </div>
                  </div>
                </div>
    
                <!-- Action Download Buttons -->
                <div class="flex flex-col gap-3 sm:flex-row">
                  <button
                    ui-button
                    aria-label="Download attachment"
                    class="flex-1 gap-2"
                    size="default"
                    (click)="isDownloaded.set(true)"
                  >
                    <lucide-icon [img]="Download" class="size-4" />
                    {{ isDownloaded() ? 'Label Downloaded (PDF)' : 'Download Prepaid Shipping Label (PDF)' }}
                  </button>
                  <button ui-button variant="outline" class="gap-2" size="default">
                    <lucide-icon [img]="Printer" class="size-4" />
                    Print Return Slip
                  </button>
                </div>
    
                <!-- Drop-off instructions & QR code card -->
                <div class="border-border bg-card space-y-4 rounded-lg border p-4">
                  <div class="flex items-center justify-between">
                    <h4 class="text-foreground text-xs font-semibold tracking-wide uppercase">
                      Carrier Drop-off Pass &amp; Instructions
                    </h4>
                    <span ui-badge variant="outline" class="text-xs">No printer required</span>
                  </div>
    
                  <div class="grid grid-cols-1 items-center gap-4 sm:grid-cols-3">
                    <div class="space-y-3 text-xs sm:col-span-2">
                      <div class="flex items-start gap-2.5">
                        <div
                          class="bg-muted text-foreground mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-medium"
                        >
                          1
                        </div>
                        <div class="space-y-0.5">
                          <p class="text-foreground font-medium">Pack your items</p>
                          <p class="text-muted-foreground">
                            Place returned items with tags inside the original shipping bag or any sturdy box.
                          </p>
                        </div>
                      </div>
    
                      <div class="flex items-start gap-2.5">
                        <div
                          class="bg-muted text-foreground mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-medium"
                        >
                          2
                        </div>
                        <div class="space-y-0.5">
                          <p class="text-foreground font-medium">Attach label or show QR code</p>
                          <p class="text-muted-foreground">
                            Tape the downloaded shipping label to the box, or present the digital QR pass at the drop-off
                            counter.
                          </p>
                        </div>
                      </div>
    
                      <div class="flex items-start gap-2.5">
                        <div
                          class="bg-muted text-foreground mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-medium"
                        >
                          3
                        </div>
                        <div class="space-y-0.5">
                          <p class="text-foreground font-medium">Drop off before Nov 15, 2026</p>
                          <p class="text-muted-foreground">
                            Bring to any USPS Post Office, USPS drop box, or authorized FedEx shipping point.
                          </p>
                        </div>
                      </div>
                    </div>
    
                    <!-- QR code visual -->
                    <div
                      class="border-border bg-muted/40 flex flex-col items-center justify-center space-y-2 rounded-lg border p-3 text-center sm:col-span-1"
                    >
                      <svg
                        class="text-foreground size-24"
                        viewBox="0 0 100 100"
                        fill="currentColor"
                        aria-label="Prepaid return shipping QR dropoff code"
                      >
                        <!-- Top-left finder -->
                        <rect
                          x="10"
                          y="10"
                          width="24"
                          height="24"
                          rx="2"
                          fill="none"
                          stroke="currentColor"
                          stroke-width="4"
                        />
                        <rect x="17" y="17" width="10" height="10" rx="1" fill="currentColor" />
                        <!-- Top-right finder -->
                        <rect
                          x="66"
                          y="10"
                          width="24"
                          height="24"
                          rx="2"
                          fill="none"
                          stroke="currentColor"
                          stroke-width="4"
                        />
                        <rect x="73" y="17" width="10" height="10" rx="1" fill="currentColor" />
                        <!-- Bottom-left finder -->
                        <rect
                          x="10"
                          y="66"
                          width="24"
                          height="24"
                          rx="2"
                          fill="none"
                          stroke="currentColor"
                          stroke-width="4"
                        />
                        <rect x="17" y="73" width="10" height="10" rx="1" fill="currentColor" />
                        <!-- Data dots -->
                        @for (dot of qrDots; track $index) {
                          <rect [attr.x]="dot[0]" [attr.y]="dot[1]" width="6" height="6" rx="1" fill="currentColor" />
                        }
                      </svg>
                      <span class="text-foreground font-mono text-xs font-semibold">RET-2026-849</span>
                    </div>
                  </div>
                </div>
    
                <div class="pt-2 text-center">
                  <button
                    ui-button
                    variant="ghost"
                    size="sm"
                    class="text-muted-foreground hover:text-foreground text-xs"
                    (click)="reset()"
                  >
                    Start another return
                  </button>
                </div>
              </div>
            }
          </div>
    
          @if (step() < 4) {
            <div ui-card-footer class="border-border flex flex-wrap items-center justify-between gap-2 border-t pt-2">
              @if (step() > 1) {
                <button ui-button variant="ghost" size="sm" (click)="goToPrevious()">
                  <lucide-icon [img]="ChevronLeft" [class]="'mr-1 size-4'" />
                  Back
                </button>
              } @else {
                <div></div>
              }
    
              <span class="text-muted-foreground text-xs">Step {{ step() }} of 4</span>
    
              @if (step() === 1) {
                <button ui-button size="sm" [disabled]="!orderNumber().trim() || !email().trim()" (click)="goToNext()">
                  Find Order
                  <lucide-icon [img]="ChevronRight" [class]="'ml-1 size-4'" />
                </button>
              }
    
              @if (step() === 2) {
                <button ui-button size="sm" [disabled]="selectedItemsCount() === 0" (click)="goToNext()">
                  Choose Resolution
                  <lucide-icon [img]="ChevronRight" [class]="'ml-1 size-4'" />
                </button>
              }
    
              @if (step() === 3) {
                <button ui-button size="sm" (click)="goToNext()">
                  Confirm &amp; Print Label
                  <lucide-icon [img]="Check" [class]="'ml-1 size-4'" />
                </button>
              }
            </div>
          }
        </div>
      `,
    })
    export class UiReturnsPortalComponent implements OnChanges, OnInit {
      /** Step to open on (1 Find Order · 2 Select Items · 3 Choose Resolution · 4 Confirm & Print). */
      @Input() initialStep = 1
      @Input() initialOrderNumber = '#ORD-84920'
      @Input() initialEmail = 'sarah.connor@example.com'
      @Input('class') className?: string
    
      protected readonly steps = steps
      protected readonly orderItems = orderItems
      protected readonly returnReasonsList = returnReasonsList
      protected readonly exchangeSizesList = exchangeSizesList
      /** Data-dot modules of the QR pass, [x, y] in the 100×100 viewBox (React order). */
      protected readonly qrDots: ReadonlyArray<readonly [number, number]> = [
        [42, 12],
        [52, 12],
        [42, 24],
        [52, 24],
        [12, 42],
        [24, 42],
        [36, 42],
        [48, 42],
        [60, 42],
        [72, 42],
        [84, 42],
        [42, 54],
        [54, 54],
        [66, 54],
        [78, 54],
        [42, 66],
        [54, 66],
        [66, 78],
        [78, 66],
        [42, 78],
        [54, 78],
        [84, 78],
      ]
    
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly ChevronLeft = ChevronLeft
      protected readonly ChevronRight = ChevronRight
      protected readonly CreditCard = CreditCard
      protected readonly Download = Download
      protected readonly Gift = Gift
      protected readonly Printer = Printer
      protected readonly RefreshCw = RefreshCw
      protected readonly Shirt = Shirt
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Truck = Truck
    
      readonly step = signal(1)
      readonly orderNumber = signal('#ORD-84920')
      readonly email = signal('sarah.connor@example.com')
      readonly selectedItems = signal<Record<string, boolean>>(defaultSelectedItems())
      readonly returnQuantities = signal<Record<string, number>>(defaultReturnQuantities())
      readonly returnReasons = signal<Record<string, string>>(defaultReturnReasons())
      readonly resolution = signal<ReturnsPortalResolution>('store-credit')
      readonly exchangeSize = signal('Medium (M)')
      readonly isDownloaded = signal(false)
    
      readonly selectedItemsCount = computed(() =>
        orderItems.reduce((acc, item) => (this.isSelected(item.id) ? acc + this.quantityFor(item.id) : acc), 0),
      )
    
      readonly itemsSubtotal = computed(() =>
        orderItems.reduce(
          (acc, item) => (this.isSelected(item.id) ? acc + item.price * this.quantityFor(item.id) : acc),
          0,
        ),
      )
    
      readonly storeCreditBonus = computed(() => this.itemsSubtotal() * 0.1)
      readonly totalStoreCredit = computed(() => this.itemsSubtotal() + this.storeCreditBonus())
    
      readonly totalLabel = computed(() => {
        const r = this.resolution()
        return r === 'store-credit' ? 'Total Store Credit' : r === 'exchange' ? 'Exchange Value' : 'Total Refund'
      })
    
      readonly totalValue = computed(() =>
        this.resolution() === 'store-credit'
          ? this.formatCurrency(this.totalStoreCredit())
          : this.formatCurrency(this.itemsSubtotal()),
      )
    
      readonly resolutionSummary = computed(() => {
        const r = this.resolution()
        return r === 'store-credit'
          ? `Store Credit (${this.formatCurrency(this.totalStoreCredit())})`
          : r === 'exchange'
            ? `Exchange (${this.exchangeSize()})`
            : 'Refund to Visa •••• 4242'
      })
    
      get cardClass(): string {
        return cn('border-border mx-auto w-full max-w-3xl shadow-xs', this.className)
      }
    
      ngOnChanges(changes: SimpleChanges): void {
        // React re-syncs the step whenever `initialStep` changes (useEffect on initialStep).
        if (changes['initialStep']) this.step.set(this.initialStep)
      }
    
      ngOnInit(): void {
        this.orderNumber.set(this.initialOrderNumber)
        this.email.set(this.initialEmail)
      }
    
      formatCurrency(val: number): string {
        return currency.format(val)
      }
    
      isSelected(id: string): boolean {
        return this.selectedItems()[id] ?? false
      }
    
      quantityFor(id: string): number {
        return this.returnQuantities()[id] || 1
      }
    
      quantityValue(id: string): string {
        return String(this.quantityFor(id))
      }
    
      reasonFor(id: string): string | undefined {
        return this.returnReasons()[id]
      }
    
      quantityOptions(maxQty: number): number[] {
        return Array.from({ length: maxQty }, (_, idx) => idx + 1)
      }
    
      itemClass(id: string): string {
        return cn(
          'rounded-lg border p-4 transition-colors',
          this.isSelected(id)
            ? 'border-primary/50 bg-primary/[0.02] dark:bg-primary/10 shadow-xs'
            : 'border-border bg-card hover:bg-muted/20',
        )
      }
    
      optionClass(value: ReturnsPortalResolution): string {
        return cn(OPTION_BASE, this.resolution() === value ? OPTION_ACTIVE : OPTION_IDLE)
      }
    
      setQuantity(id: string, value: string): void {
        this.returnQuantities.update((prev) => ({ ...prev, [id]: Number(value) }))
      }
    
      setReason(id: string, value: string): void {
        this.returnReasons.update((prev) => ({ ...prev, [id]: value }))
      }
    
      setResolution(value: string): void {
        this.resolution.set(value as ReturnsPortalResolution)
      }
    
      onStepperInput(value: number): void {
        // Free backward navigation until the return is confirmed; forward goes through the footer.
        const current = this.step()
        if (value < current && current !== 4) this.step.set(value)
      }
    
      goToNext(): void {
        if (this.step() < 4) this.step.update((s) => s + 1)
      }
    
      goToPrevious(): void {
        const current = this.step()
        if (current > 1 && current < 4) this.step.set(current - 1)
      }
    
      toggleItemSelection(id: string): void {
        this.selectedItems.update((prev) => ({ ...prev, [id]: !prev[id] }))
      }
    
      reset(): void {
        this.step.set(1)
        this.isDownloaded.set(false)
        this.selectedItems.set(defaultSelectedItems())
        this.returnQuantities.set(defaultReturnQuantities())
        this.returnReasons.set(defaultReturnReasons())
        this.resolution.set('store-credit')
        this.exchangeSize.set('Medium (M)')
      }
    }
    
  • src/app/components/blocks/returns-portal/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/returns-portal.json