Menu

Change language

GitHub repo

Loyalty Rewards Tier Hub

blockcommerce

Sephora and Starbucks style VIP customer loyalty tier dashboard, points wallet, and rewards catalog. Features a customer hero card with points balance, reward valuation, and tier progress tracker, 4 VIP privilege perks with unlock statuses, a 3-item redeemable rewards catalog grid, and a recent points earnings & redemption history ledger.

Installation

$npx uipkge-ng@latest add loyalty-rewards-tier-hub
Manifest:https://uipkge.dev/r/angular/loyalty-rewards-tier-hub.jsonInstalls to:src/app/components/blocks/loyalty-rewards-tier-hub/

Variants

Loading interactive previews…

Schema

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

LoyaltyPerk
interface LoyaltyPerk {
  id: string
  title: string
  subtitle: string
  description: string
  status: 'active' | 'locked'
  tierBadge: string
  unlockPoints?: number
}
LoyaltyReward
interface LoyaltyReward {
  id: string
  title: string
  category: string
  pointsCost: number
  description: string
  unlocked: boolean
  pointsNeeded?: number
  code?: string
}
LoyaltyLedgerEntry
interface LoyaltyLedgerEntry {
  id: string
  title: string
  subtitle: string
  type: string
  date: string
  pointsChange: number
  isPositive: boolean
}

Files installed (2)

  • src/app/components/blocks/loyalty-rewards-tier-hub/loyalty-rewards-tier-hub.component.ts25 kB
    import { Component, Input, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      Award,
      Check,
      Crown,
      Gift,
      Headphones,
      History,
      Lock,
      LucideAngularModule,
      ShoppingBag,
      Tag,
      Ticket,
      Truck,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent } 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 { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export interface LoyaltyPerk {
      id: string
      title: string
      subtitle: string
      description: string
      status: 'active' | 'locked'
      tierBadge: string
      unlockPoints?: number
    }
    
    export interface LoyaltyReward {
      id: string
      title: string
      category: string
      pointsCost: number
      description: string
      unlocked: boolean
      pointsNeeded?: number
      code?: string
    }
    
    export interface LoyaltyLedgerEntry {
      id: string
      title: string
      subtitle: string
      type: string
      date: string
      pointsChange: number
      isPositive: boolean
    }
    
    const perks: LoyaltyPerk[] = [
      {
        id: 'perk-shipping',
        title: 'Free Express Shipping on All Orders',
        subtitle: 'All Orders',
        description: 'Complimentary 2-day express shipping on every online order with zero minimum spend.',
        status: 'active',
        tierBadge: 'Active',
      },
      {
        id: 'perk-archive',
        title: 'Early Access to Private Archive Sales',
        subtitle: '48h Early Access',
        description: 'Shop seasonal private archives and limited capsule releases 48 hours before public launch.',
        status: 'active',
        tierBadge: 'Active',
      },
      {
        id: 'perk-stylist',
        title: 'Dedicated VIP Concierge & Personal Stylist',
        subtitle: '1-on-1 Stylist Line',
        description: 'Direct priority chat with our senior stylists for bespoke fashion curation and concierge care.',
        status: 'active',
        tierBadge: 'Active',
      },
      {
        id: 'perk-annual-gift',
        title: 'Diamond Exclusive Annual Gift',
        subtitle: 'Bespoke Curated Box',
        description: 'Curated luxury anniversary gift box ($120+ retail value) delivered automatically each winter.',
        status: 'locked',
        tierBadge: 'Unlocks at 3,000 pts',
        unlockPoints: 3000,
      },
    ]
    
    const rewards: LoyaltyReward[] = [
      {
        id: 'reward-15-off',
        title: '$15 Off Any Order',
        category: 'Store Credit Voucher',
        pointsCost: 1500,
        description: 'Instant digital coupon applied at checkout on any order above $50.',
        unlocked: true,
        code: 'VIP-SAVE15',
      },
      {
        id: 'reward-leather-pouch',
        title: 'Free Travel Leather Pouch',
        category: 'Exclusive Merchandise',
        pointsCost: 2000,
        description: 'Handcrafted Italian pebbled leather travel accessory pouch in signature Midnight Noir.',
        unlocked: true,
        code: 'CLAIM-POUCH24',
      },
      {
        id: 'reward-audio-30',
        title: '$30 Off Premium Audio',
        category: 'Partner Exclusive',
        pointsCost: 3000,
        description: 'Exclusive member markdown on noise-cancelling headphones and studio sound gear.',
        unlocked: false,
        pointsNeeded: 550,
      },
    ]
    
    const ledgerTransactions: LoyaltyLedgerEntry[] = [
      {
        id: 'tx-1',
        title: 'Order #ORD-9428 - Studio Silk Blouse',
        subtitle: 'Online Purchase · $190.00 subtotal',
        type: 'Earned · 2x Platinum Multiplier',
        date: 'Oct 18, 2024',
        pointsChange: 380,
        isPositive: true,
      },
      {
        id: 'tx-2',
        title: 'Redeemed: $10 Voucher Code #VIP-10OFF',
        subtitle: 'Rewards Catalog · Checkout Redemption',
        type: 'Reward Redemption',
        date: 'Oct 10, 2024',
        pointsChange: -1000,
        isPositive: false,
      },
      {
        id: 'tx-3',
        title: 'Order #ORD-9104 - Cashmere Wrap',
        subtitle: 'Online Purchase · $320.00 subtotal',
        type: 'Earned · 2x Platinum Multiplier',
        date: 'Sep 29, 2024',
        pointsChange: 640,
        isPositive: true,
      },
      {
        id: 'tx-4',
        title: 'Tier Upgrade Bonus - Platinum Milestone',
        subtitle: 'Milestone Threshold · 2,000 pts Achieved',
        type: 'Milestone Tier Reward',
        date: 'Sep 15, 2024',
        pointsChange: 500,
        isPositive: true,
      },
    ]
    
    /**
     * VIP loyalty tier dashboard: customer hero card with points balance, multiplier and
     * next-tier progress, the four tier privilege perks, a three-item rewards catalog with
     * redeemable vouchers, and the points history ledger. Angular port of the React
     * `LoyaltyRewardsTierHub` block (same markup, classes, copy and sample data).
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-loyalty-rewards-tier-hub, [ui-loyalty-rewards-tier-hub]',
      standalone: true,
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiProgressComponent,
        UiSeparatorComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      host: {
        '[attr.data-slot]': '"loyalty-rewards-tier-hub"',
        '[class]': 'hostClass',
      },
      template: `
        <!-- Header Section -->
        <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          <div>
            <div class="flex items-center gap-2">
              <span ui-badge wrap variant="secondary" class="gap-1.5 font-medium">
                <lucide-icon [img]="Award" class="text-primary size-3.5" aria-hidden="true" />
                VIP Rewards Club
              </span>
            </div>
            <h1 class="text-foreground mt-2 text-2xl font-bold tracking-tight sm:text-3xl">Loyalty &amp; Rewards Hub</h1>
            <p class="text-muted-foreground mt-1 max-w-2xl text-sm sm:text-base">
              Track VIP tier progress, redeem points for luxury perks, and review your member transaction ledger.
            </p>
          </div>
        </div>
    
        <!-- Customer Loyalty Hero Card -->
        <div ui-card class="border-border/80 shadow-xs">
          <div ui-card-content class="space-y-6 p-6">
            <!-- Profile Header & Tier Status -->
            <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
              <div class="flex items-center gap-4">
                <span ui-avatar class="border-primary/20 size-14 border-2 shadow-xs">
                  <span ui-avatar-fallback class="bg-primary/10 text-primary text-base font-semibold">ER</span>
                </span>
                <div>
                  <h2 class="text-foreground text-lg font-bold tracking-tight sm:text-xl">{{ customerName }}</h2>
                  <p class="text-muted-foreground text-xs font-medium sm:text-sm">{{ tierName }} · Member since 2022</p>
                </div>
              </div>
    
              <span
                ui-badge
                wrap
                variant="outline"
                class="border-primary/20 bg-primary/10 text-primary gap-1.5 px-3 py-1.5 text-xs font-semibold"
              >
                <lucide-icon [img]="Crown" class="text-primary size-3.5 shrink-0" aria-hidden="true" />
                <span>Platinum Tier · 2x Points Multiplier</span>
              </span>
            </div>
    
            <div ui-separator></div>
    
            <!-- Points Balance & Multiplier Display -->
            <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
              <!-- Balance Stat -->
              <div class="border-border/60 bg-muted/30 flex flex-col justify-between rounded-xl border p-4">
                <div>
                  <p class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                    Available Points Balance
                  </p>
                  <div class="mt-1 flex items-baseline gap-2">
                    <span class="text-foreground text-3xl font-bold tracking-tight tabular-nums sm:text-4xl">2,450</span>
                    <span class="text-muted-foreground text-sm font-medium">Points</span>
                  </div>
                </div>
                <p class="text-muted-foreground mt-3 text-xs font-medium">
                  Estimated reward cash value:
                  <span class="text-success text-success font-semibold tabular-nums">$24.50 reward value</span>
                </p>
              </div>
    
              <!-- Tier Multiplier Stat -->
              <div class="border-border/60 bg-muted/30 flex flex-col justify-between rounded-xl border p-4">
                <div>
                  <p class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Current Multiplier</p>
                  <div class="mt-1 flex items-center gap-2">
                    <span class="text-foreground text-3xl font-bold tracking-tight tabular-nums sm:text-4xl">2x</span>
                    <span ui-badge wrap variant="success" class="gap-1 text-xs">
                      <lucide-icon [img]="Zap" class="size-3" aria-hidden="true" />
                      Active
                    </span>
                  </div>
                </div>
                <p class="text-muted-foreground mt-3 text-xs font-medium">
                  Earn 2 points per $1 spent on all boutique catalog orders.
                </p>
              </div>
    
              <!-- Next Reward Threshold -->
              <div
                class="border-border/60 bg-muted/30 flex flex-col justify-between rounded-xl border p-4 sm:col-span-2 lg:col-span-1"
              >
                <div>
                  <p class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Next Tier Goal</p>
                  <div class="mt-1 flex items-baseline gap-2">
                    <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">550 pts</span>
                    <span class="text-muted-foreground text-xs font-medium">to Diamond VIP</span>
                  </div>
                </div>
                <p class="text-muted-foreground mt-3 text-xs font-medium">
                  Unlocks 3x points multiplier and annual exclusive gift.
                </p>
              </div>
            </div>
    
            <!-- Next Tier Progress Bar Section -->
            <div class="border-border/80 bg-muted/40 space-y-2.5 rounded-xl border p-4">
              <div class="flex flex-wrap items-center justify-between gap-2 text-xs sm:text-sm">
                <div class="flex items-center gap-2">
                  <lucide-icon [img]="Award" class="text-primary size-4 shrink-0" aria-hidden="true" />
                  <span class="text-foreground font-semibold">Next Tier Progress: Diamond VIP</span>
                </div>
                <span class="text-foreground font-semibold tabular-nums">2,450 / 3,000 Points</span>
              </div>
    
              <div ui-progress [value]="81.6" class="h-2.5"></div>
    
              <div class="flex flex-wrap items-center justify-between gap-2 text-xs">
                <span class="text-muted-foreground font-medium tabular-nums">
                  81.6% to Diamond VIP · 550 points to upgrade
                </span>
                <span class="text-muted-foreground">Target qualification date: Dec 31, 2024</span>
              </div>
            </div>
          </div>
        </div>
    
        <!-- 4 VIP Tier Privilege Perks Summary -->
        <div class="space-y-3">
          <div class="flex items-center justify-between gap-2">
            <div>
              <h3 class="text-foreground text-base font-semibold">VIP Tier Privilege Perks</h3>
              <p class="text-muted-foreground text-xs">
                Active benefits unlocked with your Platinum status and next-tier Diamond previews.
              </p>
            </div>
            <span ui-badge wrap variant="outline" class="shrink-0 text-xs font-medium tabular-nums">
              3 of 4 Perks Active
            </span>
          </div>
    
          <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
            @for (perk of perks; track perk.id) {
              <div
                [class]="
                  cn(
                    'flex flex-col justify-between rounded-xl border p-4 transition-colors',
                    perk.status === 'active'
                      ? 'border-border/80 bg-card shadow-xs'
                      : 'border-border/60 bg-muted/20 opacity-85'
                  )
                "
              >
                <div class="space-y-3">
                  <div class="flex items-center justify-between gap-2">
                    <div [class]="perkIconClass(perk)">
                      @if (perk.id === 'perk-shipping') {
                        <lucide-icon [img]="Truck" class="size-4" aria-hidden="true" />
                      }
                      @if (perk.id === 'perk-archive') {
                        <lucide-icon [img]="Tag" class="size-4" aria-hidden="true" />
                      }
                      @if (perk.id === 'perk-stylist') {
                        <lucide-icon [img]="Headphones" class="size-4" aria-hidden="true" />
                      }
                      @if (perk.id === 'perk-annual-gift') {
                        <lucide-icon [img]="Gift" class="size-4" aria-hidden="true" />
                      }
                    </div>
    
                    @if (perk.status === 'active') {
                      <span ui-badge wrap variant="success" class="gap-1 text-xs">
                        <lucide-icon [img]="Check" class="size-3" aria-hidden="true" />
                        Active
                      </span>
                    } @else {
                      <span ui-badge wrap variant="secondary" class="text-muted-foreground gap-1 text-xs">
                        <lucide-icon [img]="Lock" class="size-3" aria-hidden="true" />
                        Unlocks at 3,000 pts
                      </span>
                    }
                  </div>
    
                  <div>
                    <p class="text-foreground text-sm font-semibold">{{ perk.title }}</p>
                    <p class="text-muted-foreground mt-0.5 text-xs font-medium">{{ perk.subtitle }}</p>
                    <p class="text-muted-foreground mt-2 text-xs leading-relaxed">{{ perk.description }}</p>
                  </div>
                </div>
    
                <div class="border-border/50 mt-4 border-t pt-3">
                  <span class="text-muted-foreground text-xs">
                    {{ perk.status === 'active' ? 'Included in Platinum VIP' : 'Requires Diamond Tier' }}
                  </span>
                </div>
              </div>
            }
          </div>
        </div>
    
        <!-- Rewards Catalog Grid (3 unlockable rewards) -->
        <div class="space-y-3">
          <div class="flex items-center justify-between gap-2">
            <div>
              <h3 class="text-foreground text-base font-semibold">Rewards Catalog</h3>
              <p class="text-muted-foreground text-xs">
                Redeem your points balance for instant discount vouchers and complimentary luxury items.
              </p>
            </div>
            <span ui-badge wrap variant="outline" class="shrink-0 text-xs font-medium tabular-nums">
              2 Available to Redeem
            </span>
          </div>
    
          <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
            @for (reward of rewards; track reward.id) {
              <div
                ui-card
                [class]="
                  cn(
                    'border-border/80 flex flex-col justify-between shadow-xs transition-colors',
                    !reward.unlocked && 'bg-muted/20 opacity-90'
                  )
                "
              >
                <div ui-card-header class="pb-3">
                  <div class="flex items-center justify-between gap-2">
                    <span ui-badge wrap variant="secondary" class="min-w-0 shrink text-xs font-medium">
                      {{ reward.category }}
                    </span>
                    <div class="shrink-0 text-right">
                      <span class="text-foreground text-sm font-bold tabular-nums">
                        {{ formatPoints(reward.pointsCost) }} pts
                      </span>
                    </div>
                  </div>
                  <h3 ui-card-title class="mt-2 text-base font-semibold">{{ reward.title }}</h3>
                  <p ui-card-description class="text-xs leading-relaxed">{{ reward.description }}</p>
                </div>
    
                <div ui-card-content class="pb-3">
                  <!-- When locked, show points shortfall -->
                  @if (!reward.unlocked) {
                    <div class="space-y-1.5">
                      <div class="flex items-center justify-between text-xs">
                        <span class="text-muted-foreground">Progress to unlock</span>
                        <span class="text-foreground font-semibold tabular-nums">2,450 / 3,000 pts</span>
                      </div>
                      <div ui-progress [value]="81.6" class="h-1.5"></div>
                    </div>
                  } @else if (isRedeemed(reward.id)) {
                    <div
                      class="border-success/30 bg-success/10 text-success rounded-md border p-2.5 text-center text-xs font-medium"
                    >
                      <div class="flex items-center justify-center gap-1">
                        <lucide-icon [img]="Check" class="size-3.5" aria-hidden="true" />
                        <span>Redeemed! Code:</span>
                        <span class="font-mono font-bold">{{ reward.code }}</span>
                      </div>
                    </div>
                  }
                </div>
    
                <div ui-card-footer class="pt-0">
                  @if (reward.unlocked && !isRedeemed(reward.id)) {
                    <button ui-button type="button" class="w-full gap-1.5 text-xs font-medium" (click)="redeem(reward.id)">
                      <lucide-icon [img]="Tag" class="size-3.5" aria-hidden="true" />
                      <span>Redeem Reward</span>
                    </button>
                  }
    
                  @if (reward.unlocked && isRedeemed(reward.id)) {
                    <button
                      ui-button
                      type="button"
                      variant="outline"
                      class="border-success/40 text-success w-full gap-1.5 text-xs font-medium"
                      disabled
                    >
                      <lucide-icon [img]="Check" class="size-3.5" aria-hidden="true" />
                      <span>Voucher Claimed</span>
                    </button>
                  }
    
                  @if (!reward.unlocked) {
                    <button ui-button type="button" variant="outline" disabled class="w-full gap-1.5 text-xs font-medium">
                      <lucide-icon [img]="Lock" class="size-3.5" aria-hidden="true" />
                      <span>Locked · Need {{ reward.pointsNeeded }} pts</span>
                    </button>
                  }
                </div>
              </div>
            }
          </div>
        </div>
    
        <!-- Points History Ledger -->
        <div ui-card class="border-border/80 shadow-xs">
          <div ui-card-header class="pb-4">
            <div class="flex items-center justify-between gap-2">
              <div>
                <div class="flex items-center gap-2">
                  <lucide-icon [img]="History" class="text-muted-foreground size-4" aria-hidden="true" />
                  <h3 ui-card-title class="text-base font-semibold">Points History Ledger</h3>
                </div>
                <p ui-card-description>Recent earnings and redemption transactions on your VIP account.</p>
              </div>
              <span ui-badge wrap variant="outline" class="shrink-0 text-xs font-medium tabular-nums">
                Recent 4 Transactions
              </span>
            </div>
          </div>
    
          <div ui-card-content class="p-0 sm:p-6 sm:pt-0">
            <div class="overflow-x-auto">
              <div ui-table density="cozy">
                <thead ui-table-header>
                  <tr ui-table-row>
                    <th ui-table-head>Transaction Details</th>
                    <th ui-table-head>Type &amp; Multiplier</th>
                    <th ui-table-head>Date</th>
                    <th ui-table-head class="text-right">Points Change</th>
                  </tr>
                </thead>
                <tbody ui-table-body>
                  @for (tx of ledgerTransactions; track tx.id) {
                    <tr ui-table-row>
                      <td ui-table-cell>
                        <div class="flex items-center gap-3">
                          <div
                            [class]="
                              cn(
                                'flex size-8 shrink-0 items-center justify-center rounded-lg border',
                                tx.isPositive
                                  ? 'border-success/20 bg-success/10 text-success'
                                  : 'border-border/60 bg-muted text-muted-foreground'
                              )
                            "
                          >
                            @if (tx.isPositive && tx.pointsChange < 500) {
                              <lucide-icon [img]="ShoppingBag" class="size-4" aria-hidden="true" />
                            }
                            @if (tx.isPositive && tx.pointsChange >= 500) {
                              <lucide-icon [img]="Crown" class="size-4" aria-hidden="true" />
                            }
                            @if (!tx.isPositive) {
                              <lucide-icon [img]="Ticket" class="size-4" aria-hidden="true" />
                            }
                          </div>
                          <div class="min-w-0">
                            <p class="text-foreground truncate text-sm font-medium">{{ tx.title }}</p>
                            <p class="text-muted-foreground truncate text-xs">{{ tx.subtitle }}</p>
                          </div>
                        </div>
                      </td>
                      <td ui-table-cell>
                        @if (tx.type.includes('Multiplier')) {
                          <span ui-badge wrap variant="outline" class="gap-1 text-xs font-medium">
                            <lucide-icon [img]="Zap" class="text-warning size-3" aria-hidden="true" />
                            {{ tx.type }}
                          </span>
                        } @else if (tx.type.includes('Milestone')) {
                          <span
                            ui-badge
                            wrap
                            variant="outline"
                            class="border-primary/20 bg-primary/10 text-primary gap-1 text-xs font-medium"
                          >
                            <lucide-icon [img]="Crown" class="text-primary size-3" aria-hidden="true" />
                            {{ tx.type }}
                          </span>
                        } @else {
                          <span ui-badge wrap variant="secondary" class="text-muted-foreground gap-1 text-xs font-medium">
                            <lucide-icon [img]="Ticket" class="size-3" aria-hidden="true" />
                            {{ tx.type }}
                          </span>
                        }
                      </td>
                      <td ui-table-cell class="text-muted-foreground text-xs whitespace-nowrap sm:text-sm">
                        {{ tx.date }}
                      </td>
                      <td ui-table-cell class="text-right text-xs font-medium tabular-nums sm:text-sm">
                        <span [class]="tx.isPositive ? 'text-success font-bold' : 'text-muted-foreground font-semibold'">
                          {{
                            tx.isPositive
                              ? '+' + formatPoints(tx.pointsChange) + ' pts'
                              : formatPoints(tx.pointsChange) + ' pts'
                          }}
                        </span>
                      </td>
                    </tr>
                  }
                </tbody>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiLoyaltyRewardsTierHubComponent {
      @Input() customerName = 'Elena Rostova'
      @Input() tierName = 'VIP Platinum Member'
      @Input() currentPoints = 2450
      @Input() targetPoints = 3000
      @Input() pointValueDollars = 24.5
      @Input() multiplier = '2x Points Multiplier'
      @Input('class') className?: string
    
      protected readonly cn = cn
      protected readonly perks = perks
      protected readonly rewards = rewards
      protected readonly ledgerTransactions = ledgerTransactions
    
      protected readonly Award = Award
      protected readonly Check = Check
      protected readonly Crown = Crown
      protected readonly Gift = Gift
      protected readonly Headphones = Headphones
      protected readonly History = History
      protected readonly Lock = Lock
      protected readonly ShoppingBag = ShoppingBag
      protected readonly Tag = Tag
      protected readonly Ticket = Ticket
      protected readonly Truck = Truck
      protected readonly Zap = Zap
    
      /** Reward ids the member has redeemed in this session. */
      readonly redeemedMap = signal<Record<string, boolean>>({})
    
      get hostClass(): string {
        return cn('mx-auto block w-full max-w-5xl space-y-6', this.className)
      }
    
      isRedeemed(rewardId: string): boolean {
        return this.redeemedMap()[rewardId] === true
      }
    
      redeem(rewardId: string): void {
        this.redeemedMap.update((prev) => ({ ...prev, [rewardId]: true }))
      }
    
      protected formatPoints(value: number): string {
        return value.toLocaleString()
      }
    
      protected perkIconClass(perk: LoyaltyPerk): string {
        return cn(
          'flex size-9 shrink-0 items-center justify-center rounded-lg border',
          perk.id === 'perk-shipping' && 'border-success/20 bg-success/10 text-success',
          perk.id === 'perk-archive' && 'border-chart-1/30 bg-chart-1/10 text-chart-1',
          perk.id === 'perk-stylist' && 'border-info/20 bg-info/10 text-info',
          perk.id === 'perk-annual-gift' && 'border-border/60 bg-muted text-muted-foreground',
        )
      }
    }
    
  • src/app/components/blocks/loyalty-rewards-tier-hub/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/loyalty-rewards-tier-hub.json