Menu

Change language

GitHub repo

Freight Quote Calculator

blocklogistics

Multimodal Air, Ocean, and Ground freight rate estimator with CBM volumetric weight calculation, dynamic route pricing, value-added services, and live comparison quotes.

Installation

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

Variants

Loading interactive previews…

Schema

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

PortOption
interface PortOption {
  code: string
  name: string
}
CurrencyConfig
interface CurrencyConfig {
  code: string
  symbol: string
  label: string
  rate: number
}

Files installed (2)

  • src/app/components/blocks/freight-quote-calculator/freight-quote-calculator.component.ts42.5 kB
    // Multimodal Air, Ocean, and Ground freight rate estimator with CBM volumetric weight
    // calculation, dynamic route pricing, value-added services, and live comparison quotes.
    // Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      ArrowLeftRight,
      ArrowRight,
      Boxes,
      Check,
      CheckCircle2,
      Clock,
      Download,
      FileText,
      Info,
      Leaf,
      LucideAngularModule,
      Package,
      Plane,
      Scale,
      Ship,
      Truck,
      Zap,
    } 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 {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export type FreightMode = 'ocean-fcl' | 'ocean-lcl' | 'air' | 'ground'
    
    interface PortOption {
      code: string
      name: string
    }
    
    interface CurrencyConfig {
      code: string
      symbol: string
      label: string
      rate: number
    }
    
    const ORIGIN_PORTS: PortOption[] = [
      { code: 'CNSHA', name: 'Shanghai Port (CNSHA)' },
      { code: 'CNNGB', name: 'Ningbo-Zhoushan Port (CNNGB)' },
      { code: 'SGSIN', name: 'Port of Singapore (SGSIN)' },
      { code: 'DEHAM', name: 'Port of Hamburg (DEHAM)' },
      { code: 'NLRTM', name: 'Port of Rotterdam (NLRTM)' },
      { code: 'JPTYO', name: 'Port of Tokyo (JPTYO)' },
    ]
    
    const DESTINATION_PORTS: PortOption[] = [
      { code: 'USLAX', name: 'Port of Los Angeles (USLAX)' },
      { code: 'USNYC', name: 'Port of New York & New Jersey (USNYC)' },
      { code: 'USORD', name: 'Chicago O’Hare Logistics Hub (USORD)' },
      { code: 'GBFXT', name: 'Port of Felixstowe (GBFXT)' },
      { code: 'AUMEL', name: 'Port of Melbourne (AUMEL)' },
      { code: 'AEJEA', name: 'Jebel Ali Port (AEJEA)' },
    ]
    
    const CURRENCIES: Record<string, CurrencyConfig> = {
      USD: { code: 'USD', symbol: '$', label: 'USD ($)', rate: 1.0 },
      EUR: { code: 'EUR', symbol: '€', label: 'EUR (€)', rate: 0.92 },
      GBP: { code: 'GBP', symbol: '£', label: 'GBP (£)', rate: 0.79 },
      CNY: { code: 'CNY', symbol: '¥', label: 'CNY (¥)', rate: 7.23 },
      SGD: { code: 'SGD', symbol: 'S$', label: 'SGD (S$)', rate: 1.34 },
    }
    
    const CURRENCY_LIST = [
      CURRENCIES['USD']!,
      CURRENCIES['EUR']!,
      CURRENCIES['GBP']!,
      CURRENCIES['CNY']!,
      CURRENCIES['SGD']!,
    ]
    
    const CUSTOMS_USD = 150
    const INSURANCE_USD = 85
    const LIFTGATE_USD = 50
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-freight-quote-calculator, [ui-freight-quote-calculator]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiCheckboxComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="freight-quote-calculator" [class]="rootClass">
          <!-- Header Section -->
          <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div class="space-y-1.5">
              <div class="flex items-center gap-2">
                <span ui-badge variant="outline" class="gap-1.5 px-2.5 py-0.5 text-xs font-medium">
                  <lucide-icon [img]="Boxes" class="text-primary size-3.5" />
                  Multimodal Logistics
                </span>
                <span ui-badge variant="secondary" class="text-xs font-normal">Spot Rates Live</span>
              </div>
              <h2 class="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">
                Freight Rate &amp; Cargo Quote Calculator
              </h2>
              <p class="text-muted-foreground text-sm">
                Compare spot rates across Ocean FCL/LCL, Air Freight, and Ground Trucking with instant CBM volumetric
                analysis.
              </p>
            </div>
            <div class="flex items-center gap-2 sm:self-start">
              <span class="text-muted-foreground text-xs font-medium">Currency:</span>
              <ui-select [value]="selectedCurrency()" (valueChange)="selectedCurrency.set($event)">
                <button ui-select-trigger class="w-[125px] text-xs font-medium">
                  <ui-select-value [placeholder]="selectedCurrency()" />
                </button>
                <ui-select-content align="end">
                  @for (curr of currencyList; track curr.code) {
                    <ui-select-item [value]="curr.code" class="text-xs">{{ curr.label }}</ui-select-item>
                  }
                </ui-select-content>
              </ui-select>
            </div>
          </div>
    
          <div class="grid grid-cols-1 gap-8 lg:grid-cols-12 lg:items-start">
            <!-- Left Column -->
            <div class="space-y-6 lg:col-span-7">
              <!-- 1. Route -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-4">
                  <div class="flex flex-wrap items-center justify-between">
                    <h3 ui-card-title class="text-base font-semibold">1. Shipping Route</h3>
                    <span class="text-muted-foreground text-xs font-medium">International Corridors</span>
                  </div>
                  <p ui-card-description class="text-xs">Select origin loading port and destination discharge terminal.</p>
                </div>
                <div ui-card-content class="space-y-4">
                  <div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr,auto,1fr] sm:items-end">
                    <div class="space-y-1.5">
                      <label class="text-foreground text-xs font-medium" for="fq-origin">Origin (Port / Hub)</label>
                      <ui-select [value]="origin()" (valueChange)="origin.set($event)">
                        <button ui-select-trigger id="fq-origin" class="text-xs">
                          <ui-select-value />
                        </button>
                        <ui-select-content>
                          @for (p of originPorts; track p.code) {
                            <ui-select-item [value]="p.code" class="text-xs">{{ p.name }}</ui-select-item>
                          }
                        </ui-select-content>
                      </ui-select>
                    </div>
                    <div class="flex justify-center pb-0.5 sm:pb-0">
                      <button
                        ui-button
                        type="button"
                        variant="outline"
                        size="icon"
                        class="size-9 shrink-0"
                        aria-label="Swap Origin and Destination"
                        (click)="swapRoute()"
                      >
                        <lucide-icon [img]="ArrowLeftRight" class="size-4" />
                      </button>
                    </div>
                    <div class="space-y-1.5">
                      <label class="text-foreground text-xs font-medium" for="fq-destination">
                        Destination (Port / Hub)
                      </label>
                      <ui-select [value]="destination()" (valueChange)="destination.set($event)">
                        <button ui-select-trigger id="fq-destination" class="text-xs">
                          <ui-select-value />
                        </button>
                        <ui-select-content>
                          @for (p of destinationPorts; track p.code) {
                            <ui-select-item [value]="p.code" class="text-xs">{{ p.name }}</ui-select-item>
                          }
                        </ui-select-content>
                      </ui-select>
                    </div>
                  </div>
                  <div
                    class="bg-muted/40 border-border/80 flex flex-wrap items-center justify-between rounded-lg border px-3 py-2 text-xs"
                  >
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="Ship" class="text-primary size-4 shrink-0" />
                      <span class="text-foreground font-medium">Transpacific Direct Corridor</span>
                    </div>
                    <span class="text-muted-foreground tabular-nums">Distance: ~5,800 NM (10,740 km)</span>
                  </div>
                </div>
              </div>
    
              <!-- 2. Mode -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-4">
                  <div class="flex flex-wrap items-center justify-between">
                    <h3 ui-card-title class="text-base font-semibold">2. Cargo Freight Mode</h3>
                    <span class="text-muted-foreground text-xs">Select primary transport</span>
                  </div>
                  <p ui-card-description class="text-xs">
                    Choose your preferred multimodal transit mode to evaluate rates.
                  </p>
                </div>
                <div ui-card-content>
                  <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
                    <button type="button" [class]="modeCardClass('ocean-fcl')" (click)="selectedMode.set('ocean-fcl')">
                      <div class="flex w-full items-start justify-between">
                        <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-md">
                          <lucide-icon [img]="Ship" class="size-4" />
                        </div>
                        <span ui-badge variant="default" class="text-xs">Recommended</span>
                      </div>
                      <div>
                        <div class="text-foreground text-sm font-semibold">Ocean FCL (Full Container)</div>
                        <div class="text-muted-foreground mt-0.5 text-xs">20ft / 40ft dedicated sea freight container</div>
                      </div>
                      <div class="border-border/50 flex w-full items-center justify-between border-t pt-1 text-xs">
                        <span class="text-muted-foreground">Est. Transit: 14-18 days</span>
                        <span class="text-foreground font-semibold tabular-nums">{{ formatMoney(oceanFclBaseUsd()) }}</span>
                      </div>
                    </button>
                    <button type="button" [class]="modeCardClass('ocean-lcl')" (click)="selectedMode.set('ocean-lcl')">
                      <div class="flex w-full items-start justify-between">
                        <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-md">
                          <lucide-icon [img]="Boxes" class="size-4" />
                        </div>
                        <span ui-badge variant="outline" class="text-xs font-normal">Economy</span>
                      </div>
                      <div>
                        <div class="text-foreground text-sm font-semibold">Ocean LCL (Shared)</div>
                        <div class="text-muted-foreground mt-0.5 text-xs">
                          Consolidated sea freight priced by CBM volume
                        </div>
                      </div>
                      <div class="border-border/50 flex w-full items-center justify-between border-t pt-1 text-xs">
                        <span class="text-muted-foreground">Est. Transit: 18-22 days</span>
                        <span class="text-foreground font-semibold tabular-nums">{{ formatMoney(oceanLclBaseUsd()) }}</span>
                      </div>
                    </button>
                    <button type="button" [class]="modeCardClass('air')" (click)="selectedMode.set('air')">
                      <div class="flex w-full items-start justify-between">
                        <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-md">
                          <lucide-icon [img]="Plane" class="size-4" />
                        </div>
                        <span ui-badge variant="secondary" class="text-xs">Fastest</span>
                      </div>
                      <div>
                        <div class="text-foreground text-sm font-semibold">Express Air Freight</div>
                        <div class="text-muted-foreground mt-0.5 text-xs">Priority belly/freighter aircraft dispatch</div>
                      </div>
                      <div class="border-border/50 flex w-full items-center justify-between border-t pt-1 text-xs">
                        <span class="text-muted-foreground">Est. Transit: 3-5 days</span>
                        <span class="text-foreground font-semibold tabular-nums">{{
                          formatMoney(airFreightBaseUsd())
                        }}</span>
                      </div>
                    </button>
                    <button type="button" [class]="modeCardClass('ground')" (click)="selectedMode.set('ground')">
                      <div class="flex w-full items-start justify-between">
                        <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-md">
                          <lucide-icon [img]="Truck" class="size-4" />
                        </div>
                        <span ui-badge variant="outline" class="text-xs font-normal">Overland</span>
                      </div>
                      <div>
                        <div class="text-foreground text-sm font-semibold">Ground Freight</div>
                        <div class="text-muted-foreground mt-0.5 text-xs">
                          Regional linehaul, FTL &amp; pallet LTL trucking
                        </div>
                      </div>
                      <div class="border-border/50 flex w-full items-center justify-between border-t pt-1 text-xs">
                        <span class="text-muted-foreground">Est. Transit: 5-7 days</span>
                        <span class="text-foreground font-semibold tabular-nums">{{
                          formatMoney(groundFreightBaseUsd())
                        }}</span>
                      </div>
                    </button>
                  </div>
                </div>
              </div>
    
              <!-- 3. Dimensions -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-4">
                  <div class="flex flex-wrap items-center justify-between">
                    <h3 ui-card-title class="text-base font-semibold">3. Cargo Dimensions &amp; Volumetric Weight</h3>
                    <span ui-badge variant="outline" class="gap-1 text-xs font-normal">
                      <lucide-icon [img]="Scale" class="text-primary size-3" />
                      IATA 1:6 Standard
                    </span>
                  </div>
                  <p ui-card-description class="text-xs">
                    Enter pallet quantity, unit dimensions, and individual gross weight.
                  </p>
                </div>
                <div ui-card-content class="space-y-5">
                  <div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
                    <div class="col-span-2 space-y-1.5 sm:col-span-1">
                      <label class="text-foreground text-xs font-medium" for="fq-pallets">Quantity</label>
                      <div class="relative">
                        <ui-input
                          id="fq-pallets"
                          type="number"
                          [value]="pallets()"
                          (valueChange)="setPallets($event)"
                          class="pr-8 text-xs tabular-nums"
                        />
                        <span class="text-muted-foreground absolute top-1/2 right-2.5 -translate-y-1/2 text-xs">pal</span>
                      </div>
                    </div>
                    <div class="space-y-1.5">
                      <label class="text-foreground text-xs font-medium" for="fq-length">Length (L)</label>
                      <div class="relative">
                        <ui-input
                          id="fq-length"
                          type="number"
                          [value]="lengthCm()"
                          (valueChange)="setLengthCm($event)"
                          class="pr-8 text-xs tabular-nums"
                        />
                        <span class="text-muted-foreground absolute top-1/2 right-2.5 -translate-y-1/2 text-xs">cm</span>
                      </div>
                    </div>
                    <div class="space-y-1.5">
                      <label class="text-foreground text-xs font-medium" for="fq-width">Width (W)</label>
                      <div class="relative">
                        <ui-input
                          id="fq-width"
                          type="number"
                          [value]="widthCm()"
                          (valueChange)="setWidthCm($event)"
                          class="pr-8 text-xs tabular-nums"
                        />
                        <span class="text-muted-foreground absolute top-1/2 right-2.5 -translate-y-1/2 text-xs">cm</span>
                      </div>
                    </div>
                    <div class="space-y-1.5">
                      <label class="text-foreground text-xs font-medium" for="fq-height">Height (H)</label>
                      <div class="relative">
                        <ui-input
                          id="fq-height"
                          type="number"
                          [value]="heightCm()"
                          (valueChange)="setHeightCm($event)"
                          class="pr-8 text-xs tabular-nums"
                        />
                        <span class="text-muted-foreground absolute top-1/2 right-2.5 -translate-y-1/2 text-xs">cm</span>
                      </div>
                    </div>
                    <div class="col-span-2 space-y-1.5 sm:col-span-1">
                      <label class="text-foreground text-xs font-medium" for="fq-weight">Weight/Pallet</label>
                      <div class="relative">
                        <ui-input
                          id="fq-weight"
                          type="number"
                          [value]="grossWeightKg()"
                          (valueChange)="setGrossWeightKg($event)"
                          class="pr-8 text-xs tabular-nums"
                        />
                        <span class="text-muted-foreground absolute top-1/2 right-2.5 -translate-y-1/2 text-xs">kg</span>
                      </div>
                    </div>
                  </div>
    
                  <div class="border-border/80 bg-muted/30 space-y-3 rounded-lg border p-4">
                    <div class="flex flex-wrap items-center justify-between text-xs">
                      <span class="text-foreground flex items-center gap-1.5 font-semibold">
                        <lucide-icon [img]="Package" class="text-primary size-3.5" />
                        Cargo Metric Computations
                      </span>
                      <span class="text-muted-foreground">Standard Euro/US Pallet Basis</span>
                    </div>
                    <div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
                      <div class="bg-card border-border/80 rounded-md border p-2.5">
                        <div class="text-muted-foreground text-xs">Total Volume</div>
                        <div class="text-foreground mt-0.5 text-lg font-bold tabular-nums">
                          {{ totalVolumeCbm().toFixed(2) }}
                          <span class="text-muted-foreground text-xs font-normal">CBM</span>
                        </div>
                        <div class="text-muted-foreground mt-1 text-xs">
                          {{ singlePalletVolumeCbm().toFixed(2) }} m³ / unit
                        </div>
                      </div>
                      <div class="bg-card border-border/80 rounded-md border p-2.5">
                        <div class="text-muted-foreground text-xs">Actual Gross Wt.</div>
                        <div class="text-foreground mt-0.5 text-lg font-bold tabular-nums">
                          {{ totalGrossWeightKg().toLocaleString('en-US') }}
                          <span class="text-muted-foreground text-xs font-normal">kg</span>
                        </div>
                        <div class="text-muted-foreground mt-1 text-xs">{{ pallets() }} × {{ grossWeightKg() }} kg</div>
                      </div>
                      <div class="bg-card border-border/80 rounded-md border p-2.5">
                        <div class="text-muted-foreground text-xs">Chargeable Wt. (Air)</div>
                        <div class="text-foreground mt-0.5 text-lg font-bold tabular-nums">
                          {{ airVolumetricWeightKg().toLocaleString('en-US') }}
                          <span class="text-muted-foreground text-xs font-normal">kg</span>
                        </div>
                        <div class="text-muted-foreground mt-1 text-xs">Volumetric (1:6000)</div>
                      </div>
                      <div class="bg-card border-border/80 rounded-md border p-2.5">
                        <div class="text-muted-foreground text-xs">Ocean Revenue Ton</div>
                        <div class="text-foreground mt-0.5 text-lg font-bold tabular-nums">
                          {{ oceanRevenueTon().toFixed(2) }}
                          <span class="text-muted-foreground text-xs font-normal">RT</span>
                        </div>
                        <div class="text-muted-foreground mt-1 text-xs">Max(CBM, Weight/T)</div>
                      </div>
                    </div>
                    <p class="text-muted-foreground flex items-center gap-1.5 pt-1 text-xs">
                      <lucide-icon [img]="Info" class="text-primary size-3.5 shrink-0" />
                      <span>
                        Computed Total Volume ({{ totalVolumeCbm().toFixed(2) }} CBM) &amp; Chargeable Weight ({{
                          airVolumetricWeightKg().toLocaleString('en-US')
                        }}
                        kg) apply dynamically to quotes.
                      </span>
                    </p>
                  </div>
                </div>
              </div>
    
              <!-- 4. Services -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex flex-wrap items-center justify-between">
                    <h3 ui-card-title class="text-base font-semibold">4. Value-Added Freight Services</h3>
                    <span class="text-muted-foreground text-xs font-medium">Optional Add-ons</span>
                  </div>
                  <p ui-card-description class="text-xs">
                    Enhance shipment handling with customs, all-risk insurance, and destination equipment.
                  </p>
                </div>
                <div ui-card-content class="space-y-3">
                  <label
                    class="border-border hover:border-primary/40 bg-card flex cursor-pointer items-start justify-between gap-3 rounded-lg border p-3.5 transition-colors"
                  >
                    <div class="flex items-start gap-3">
                      <ui-checkbox [checked]="addCustoms()" (checkedChange)="addCustoms.set(!!$event)" />
                      <div class="space-y-0.5">
                        <div class="flex items-center gap-2">
                          <span class="text-foreground text-sm font-medium">Customs Clearance</span>
                          <span ui-badge variant="outline" class="text-xs font-normal tabular-nums"
                            >+{{ formatMoney(customsUsd) }}</span
                          >
                        </div>
                        <p class="text-muted-foreground text-xs">
                          Export filing, ISF 10+2 documentation, automated customs broker import clearance.
                        </p>
                      </div>
                    </div>
                  </label>
                  <label
                    class="border-border hover:border-primary/40 bg-card flex cursor-pointer items-start justify-between gap-3 rounded-lg border p-3.5 transition-colors"
                  >
                    <div class="flex items-start gap-3">
                      <ui-checkbox [checked]="addInsurance()" (checkedChange)="addInsurance.set(!!$event)" />
                      <div class="space-y-0.5">
                        <div class="flex items-center gap-2">
                          <span class="text-foreground text-sm font-medium">All-Risk Cargo Insurance</span>
                          <span ui-badge variant="outline" class="text-xs font-normal tabular-nums"
                            >+{{ formatMoney(insuranceUsd) }}</span
                          >
                        </div>
                        <p class="text-muted-foreground text-xs">
                          Comprehensive door-to-door insurance coverage up to $100,000 against damage or loss.
                        </p>
                      </div>
                    </div>
                  </label>
                  <label
                    class="border-border hover:border-primary/40 bg-card flex cursor-pointer items-start justify-between gap-3 rounded-lg border p-3.5 transition-colors"
                  >
                    <div class="flex items-start gap-3">
                      <ui-checkbox [checked]="addLiftgate()" (checkedChange)="addLiftgate.set(!!$event)" />
                      <div class="space-y-0.5">
                        <div class="flex items-center gap-2">
                          <span class="text-foreground text-sm font-medium">Liftgate at Delivery</span>
                          <span ui-badge variant="outline" class="text-xs font-normal tabular-nums"
                            >+{{ formatMoney(liftgateUsd) }}</span
                          >
                        </div>
                        <p class="text-muted-foreground text-xs">
                          Hydraulic liftgate truck delivery for destinations without a dedicated loading dock.
                        </p>
                      </div>
                    </div>
                  </label>
                </div>
              </div>
            </div>
    
            <!-- Right Column -->
            <div class="space-y-6 lg:sticky lg:top-8 lg:col-span-5">
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-4">
                  <div class="flex flex-wrap items-center justify-between">
                    <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase"
                      >Instant Spot Rates</span
                    >
                    <span ui-badge variant="default" class="gap-1 text-xs">
                      <lucide-icon [img]="Zap" class="size-3" />
                      Live Quote
                    </span>
                  </div>
                  <div class="border-border/60 mt-2 flex flex-wrap items-center justify-between border-b pb-3 text-xs">
                    <div class="text-foreground flex items-center gap-1.5 font-medium">
                      <span>{{ origin() }}</span>
                      <lucide-icon [img]="ArrowRight" class="text-muted-foreground size-3" />
                      <span>{{ destination() }}</span>
                    </div>
                    <span class="text-muted-foreground tabular-nums"
                      >{{ pallets() }} Pallets · {{ totalVolumeCbm().toFixed(2) }} CBM</span
                    >
                  </div>
                  <div class="mt-4 space-y-1">
                    <div class="text-muted-foreground text-xs font-medium">Total Estimated Landed Freight Cost</div>
                    <div class="flex items-baseline gap-2">
                      <span class="text-foreground text-4xl font-bold tracking-tight tabular-nums">{{
                        formatMoney(activeTotalPriceUsd())
                      }}</span>
                      <span class="text-muted-foreground text-xs font-medium">({{ selectedCurrency() }})</span>
                    </div>
                    <div class="text-muted-foreground flex items-center gap-3 pt-1 text-xs">
                      <span class="flex items-center gap-1">
                        <lucide-icon [img]="Clock" class="text-primary size-3" />
                        Transit: {{ activeTransitTime() }}
                      </span>
                      <span>·</span>
                      <span class="flex items-center gap-1">
                        <lucide-icon [img]="Leaf" class="text-success size-3" />
                        Est. {{ activeCarbonEmission() }}
                      </span>
                    </div>
                  </div>
                </div>
                <div ui-card-content class="space-y-4 pt-0">
                  <div class="space-y-2">
                    <span class="text-foreground text-xs font-semibold tracking-wider uppercase"
                      >Compare Multimodal Options:</span
                    >
                    <div [class]="quoteOptionClass('ocean-fcl')" (click)="selectedMode.set('ocean-fcl')">
                      <div class="flex flex-wrap items-center justify-between">
                        <div class="flex items-center gap-2">
                          <lucide-icon [img]="Ship" class="text-primary size-4 shrink-0" />
                          <div>
                            <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold">
                              Ocean FCL (20ft Container)
                              <span ui-badge variant="default" class="px-1.5 py-0.5 text-xs">Recommended</span>
                            </div>
                            <div class="text-muted-foreground text-xs">Transit: 14-18 days · 1.2 tCO2</div>
                          </div>
                        </div>
                        <div class="text-right">
                          <div class="text-foreground text-sm font-bold tabular-nums">
                            {{ formatMoney(oceanFclBaseUsd() + addonsTotalUsd()) }}
                          </div>
                          <div class="text-muted-foreground text-xs">Port-to-Port</div>
                        </div>
                      </div>
                    </div>
                    <div [class]="quoteOptionClass('ocean-lcl')" (click)="selectedMode.set('ocean-lcl')">
                      <div class="flex flex-wrap items-center justify-between">
                        <div class="flex items-center gap-2">
                          <lucide-icon [img]="Boxes" class="text-primary size-4 shrink-0" />
                          <div>
                            <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold">
                              Ocean LCL (Shared)
                              <span ui-badge variant="outline" class="px-1.5 py-0.5 text-xs font-normal">Economy</span>
                            </div>
                            <div class="text-muted-foreground text-xs">Transit: 18-22 days · 0.8 tCO2</div>
                          </div>
                        </div>
                        <div class="text-right">
                          <div class="text-foreground text-sm font-bold tabular-nums">
                            {{ formatMoney(oceanLclBaseUsd() + addonsTotalUsd()) }}
                          </div>
                          <div class="text-muted-foreground text-xs">CFS-to-CFS</div>
                        </div>
                      </div>
                    </div>
                    <div [class]="quoteOptionClass('air')" (click)="selectedMode.set('air')">
                      <div class="flex flex-wrap items-center justify-between">
                        <div class="flex items-center gap-2">
                          <lucide-icon [img]="Plane" class="text-primary size-4 shrink-0" />
                          <div>
                            <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold">
                              Express Air Freight
                              <span ui-badge variant="secondary" class="px-1.5 py-0.5 text-xs">Fastest</span>
                            </div>
                            <div class="text-muted-foreground text-xs">Transit: 3-5 days · 4.8 tCO2</div>
                          </div>
                        </div>
                        <div class="text-right">
                          <div class="text-foreground text-sm font-bold tabular-nums">
                            {{ formatMoney(airFreightBaseUsd() + addonsTotalUsd()) }}
                          </div>
                          <div class="text-muted-foreground text-xs">Airport-to-Airport</div>
                        </div>
                      </div>
                    </div>
                  </div>
                  <ui-separator />
                  <div class="space-y-2 text-xs">
                    <span class="text-muted-foreground font-medium tracking-wider uppercase">Itemized Breakdown</span>
                    <div class="space-y-1.5">
                      <div class="flex flex-wrap items-center justify-between">
                        <span class="text-muted-foreground">Base Freight Carrier Rate</span>
                        <span class="text-foreground font-medium tabular-nums">{{
                          formatMoney(activeBasePriceUsd())
                        }}</span>
                      </div>
                      @if (addCustoms()) {
                        <div class="flex flex-wrap items-center justify-between">
                          <span class="text-muted-foreground">Customs Clearance Service</span>
                          <span class="text-foreground font-medium tabular-nums">+{{ formatMoney(customsUsd) }}</span>
                        </div>
                      }
                      @if (addInsurance()) {
                        <div class="flex flex-wrap items-center justify-between">
                          <span class="text-muted-foreground">All-Risk Cargo Insurance</span>
                          <span class="text-foreground font-medium tabular-nums">+{{ formatMoney(insuranceUsd) }}</span>
                        </div>
                      }
                      @if (addLiftgate()) {
                        <div class="flex flex-wrap items-center justify-between">
                          <span class="text-muted-foreground">Destination Liftgate Equipment</span>
                          <span class="text-foreground font-medium tabular-nums">+{{ formatMoney(liftgateUsd) }}</span>
                        </div>
                      }
                      <div class="text-muted-foreground flex flex-wrap items-center justify-between">
                        <span>Bunker / Fuel Surcharge (BAF)</span>
                        <span class="text-success font-medium">Included</span>
                      </div>
                    </div>
                  </div>
                  <ui-separator />
                  <ul class="text-muted-foreground space-y-1.5 text-xs">
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-primary size-3.5 shrink-0" />
                      <span>Rate locked for 7 calendar days</span>
                    </li>
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-primary size-3.5 shrink-0" />
                      <span>IATA &amp; FMC compliant licensed forwarders</span>
                    </li>
                    <li class="flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-primary size-3.5 shrink-0" />
                      <span>Automated EDI customs documentation &amp; bill of lading</span>
                    </li>
                  </ul>
                </div>
                <div ui-card-footer class="flex flex-col gap-2.5 pt-2">
                  <button ui-button class="w-full gap-2 font-semibold shadow-xs" size="lg" (click)="handleBookQuote()">
                    @if (isBooked()) {
                      <lucide-icon [img]="CheckCircle2" class="text-success size-4" />
                      Freight Quote Booked!
                    } @else {
                      Book Freight Quote
                      <lucide-icon [img]="ArrowRight" class="size-4" />
                    }
                  </button>
                  <button
                    ui-button
                    aria-label="Download attachment"
                    variant="outline"
                    class="w-full gap-2 text-xs"
                    size="default"
                    (click)="handleDownloadPdf()"
                  >
                    @if (!isDownloading()) {
                      <lucide-icon [img]="Download" class="size-3.5" />
                    } @else {
                      <lucide-icon [img]="FileText" class="size-3.5 animate-pulse" />
                    }
                    {{ isDownloading() ? 'Generating PDF Manifest...' : 'Download Detailed Quote PDF' }}
                  </button>
                </div>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiFreightQuoteCalculatorComponent {
      protected readonly ArrowLeftRight = ArrowLeftRight
      protected readonly ArrowRight = ArrowRight
      protected readonly Boxes = Boxes
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly Download = Download
      protected readonly FileText = FileText
      protected readonly Info = Info
      protected readonly Leaf = Leaf
      protected readonly Package = Package
      protected readonly Plane = Plane
      protected readonly Scale = Scale
      protected readonly Ship = Ship
      protected readonly Truck = Truck
      protected readonly Zap = Zap
    
      protected readonly originPorts = ORIGIN_PORTS
      protected readonly destinationPorts = DESTINATION_PORTS
      protected readonly currencyList = CURRENCY_LIST
      protected readonly customsUsd = CUSTOMS_USD
      protected readonly insuranceUsd = INSURANCE_USD
      protected readonly liftgateUsd = LIFTGATE_USD
    
      @Input() set initialOrigin(v: string) {
        if (v) this.origin.set(v)
      }
      @Input() set initialDestination(v: string) {
        if (v) this.destination.set(v)
      }
      @Input() set initialPallets(v: number | string) {
        const n = Number(v)
        if (n !== undefined && n !== null && !Number.isNaN(n)) this.pallets.set(n)
      }
      @Input() set initialLengthCm(v: number | string) {
        const n = Number(v)
        if (n !== undefined && n !== null && !Number.isNaN(n)) this.lengthCm.set(n)
      }
      @Input() set initialWidthCm(v: number | string) {
        const n = Number(v)
        if (n !== undefined && n !== null && !Number.isNaN(n)) this.widthCm.set(n)
      }
      @Input() set initialHeightCm(v: number | string) {
        const n = Number(v)
        if (n !== undefined && n !== null && !Number.isNaN(n)) this.heightCm.set(n)
      }
      @Input() set initialGrossWeightKg(v: number | string) {
        const n = Number(v)
        if (n !== undefined && n !== null && !Number.isNaN(n)) this.grossWeightKg.set(n)
      }
      @Input() set initialCurrency(v: string) {
        if (v) this.selectedCurrency.set(v)
      }
      @Input('class') className?: string
    
      readonly origin = signal('CNSHA')
      readonly destination = signal('USLAX')
      readonly pallets = signal(6)
      readonly lengthCm = signal(120)
      readonly widthCm = signal(80)
      readonly heightCm = signal(160)
      readonly grossWeightKg = signal(400)
      readonly selectedCurrency = signal('USD')
    
      readonly addCustoms = signal(false)
      readonly addInsurance = signal(false)
      readonly addLiftgate = signal(false)
    
      readonly selectedMode = signal<FreightMode>('ocean-fcl')
      readonly isBooked = signal(false)
      readonly isDownloading = signal(false)
      private bookTimer: ReturnType<typeof setTimeout> | undefined
      private downloadTimer: ReturnType<typeof setTimeout> | undefined
    
      readonly singlePalletVolumeCbm = computed(() => {
        const l = Number(this.lengthCm()) || 0
        const w = Number(this.widthCm()) || 0
        const h = Number(this.heightCm()) || 0
        return (l * w * h) / 1_000_000
      })
      readonly totalVolumeCbm = computed(() => (Number(this.pallets()) || 0) * this.singlePalletVolumeCbm())
      readonly totalGrossWeightKg = computed(() => (Number(this.pallets()) || 0) * (Number(this.grossWeightKg()) || 0))
      readonly airVolumetricWeightKg = computed(() => Math.round(this.totalVolumeCbm() * (1000 / 6)))
      readonly oceanRevenueTon = computed(() => Math.max(this.totalVolumeCbm(), this.totalGrossWeightKg() / 1000))
      readonly fclContainersCount = computed(() => Math.max(1, Math.ceil((Number(this.pallets()) || 0) / 10)))
      readonly oceanFclBaseUsd = computed(() => this.fclContainersCount() * 2850)
      readonly oceanLclBaseUsd = computed(() => Math.round((this.totalVolumeCbm() || 1) * 140 + 130))
      readonly airFreightBaseUsd = computed(() => Math.round((this.airVolumetricWeightKg() || 100) * 4.18))
      readonly groundFreightBaseUsd = computed(() => (Number(this.pallets()) || 1) * 265)
      readonly addonsTotalUsd = computed(
        () =>
          (this.addCustoms() ? CUSTOMS_USD : 0) +
          (this.addInsurance() ? INSURANCE_USD : 0) +
          (this.addLiftgate() ? LIFTGATE_USD : 0),
      )
      readonly activeBasePriceUsd = computed(() => {
        switch (this.selectedMode()) {
          case 'ocean-fcl':
            return this.oceanFclBaseUsd()
          case 'ocean-lcl':
            return this.oceanLclBaseUsd()
          case 'air':
            return this.airFreightBaseUsd()
          case 'ground':
            return this.groundFreightBaseUsd()
        }
      })
      readonly activeTotalPriceUsd = computed(() => this.activeBasePriceUsd() + this.addonsTotalUsd())
      readonly activeTransitTime = computed(() => {
        switch (this.selectedMode()) {
          case 'ocean-fcl':
            return '14-18 days'
          case 'ocean-lcl':
            return '18-22 days'
          case 'air':
            return '3-5 days'
          case 'ground':
            return '5-7 days'
        }
      })
      readonly activeCarbonEmission = computed(() => {
        switch (this.selectedMode()) {
          case 'ocean-fcl':
            return `${(1.2 * this.fclContainersCount()).toFixed(1)} tCO2`
          case 'ocean-lcl':
            return `${(this.totalVolumeCbm() * 0.087).toFixed(1)} tCO2`
          case 'air':
            return `${(this.airVolumetricWeightKg() * 0.003125).toFixed(1)} tCO2`
          case 'ground':
            return `${(Number(this.pallets()) * 0.26).toFixed(1)} tCO2`
        }
      })
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-6xl space-y-8 p-4 sm:p-6 lg:p-8', this.className)
      }
    
      modeCardClass(mode: FreightMode): string {
        return cn(
          'border-border focus-visible:ring-ring relative flex flex-col justify-between gap-3 rounded-lg border p-3.5 text-left transition-colors outline-none focus-visible:ring-2',
          this.selectedMode() === mode
            ? 'border-primary bg-primary/5 ring-primary ring-1'
            : 'hover:border-border/80 hover:bg-muted/30 bg-card',
        )
      }
    
      quoteOptionClass(mode: FreightMode): string {
        return cn(
          'border-border hover:border-primary/50 cursor-pointer rounded-lg border p-3 transition-colors',
          this.selectedMode() === mode ? 'border-primary bg-primary/5 ring-primary ring-1' : 'bg-card',
        )
      }
    
      formatMoney(amountInUsd: number): string {
        const current = CURRENCIES[this.selectedCurrency()] ?? CURRENCIES['USD']!
        const converted = amountInUsd * current.rate
        return new Intl.NumberFormat('en-US', {
          style: 'currency',
          currency: current.code,
          minimumFractionDigits: 2,
          maximumFractionDigits: 2,
        }).format(converted)
      }
    
      swapRoute(): void {
        const prev = this.origin()
        this.origin.set(this.destination())
        this.destination.set(prev)
      }
    
      setPallets(raw: string | number): void {
        this.pallets.set(Math.max(1, parseInt(String(raw), 10) || 1))
      }
      setLengthCm(raw: string | number): void {
        this.lengthCm.set(Math.max(1, parseInt(String(raw), 10) || 1))
      }
      setWidthCm(raw: string | number): void {
        this.widthCm.set(Math.max(1, parseInt(String(raw), 10) || 1))
      }
      setHeightCm(raw: string | number): void {
        this.heightCm.set(Math.max(1, parseInt(String(raw), 10) || 1))
      }
      setGrossWeightKg(raw: string | number): void {
        this.grossWeightKg.set(Math.max(1, parseInt(String(raw), 10) || 1))
      }
    
      handleBookQuote(): void {
        this.isBooked.set(true)
        clearTimeout(this.bookTimer)
        this.bookTimer = setTimeout(() => this.isBooked.set(false), 3500)
      }
    
      handleDownloadPdf(): void {
        this.isDownloading.set(true)
        clearTimeout(this.downloadTimer)
        this.downloadTimer = setTimeout(() => this.isDownloading.set(false), 2000)
      }
    }
    
  • src/app/components/blocks/freight-quote-calculator/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/freight-quote-calculator.json