Returns Portal
blockcommerceSelf-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
$pnpm dlx uipkge-ng@latest add returns-portal$npx uipkge-ng@latest add returns-portal$yarn dlx uipkge-ng@latest add returns-portal$bunx uipkge-ng@latest add returns-portalManifest:
https://uipkge.dev/r/angular/returns-portal.jsonInstalls to:src/app/components/blocks/returns-portal/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
OrderIteminterface OrderItem {
id: string
name: string
variant: string
sku: string
price: number
maxQty: number
}npm dependencies
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 & 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 & 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 & 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 & 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
export { UiReturnsPortalComponent, type OrderItem, type ReturnsPortalResolution } from './returns-portal.component'
Raw manifest:https://uipkge.dev/r/angular/returns-portal.json