1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-14 07:23:45 +00:00
Files
browser/apps/web/src/app/billing/payment/components/enter-billing-address.component.ts
cyprain-okeke b9f48d83b2 [PM 25897] Copy and UI Tweaks for Payment Method Component (#16851)
* Implement the Ui changes to align as expected

* Align the Text in card number, expiration date and security code vertically

* Change the Zip to ZIP

* Remove readonly modifier from signal declarations
2025-10-27 13:53:05 +01:00

277 lines
9.0 KiB
TypeScript

import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { map, Observable, startWith, Subject, takeUntil } from "rxjs";
import { ControlsOf } from "@bitwarden/angular/types/controls-of";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
TaxIdWarningType,
TaxIdWarningTypes,
} from "@bitwarden/web-vault/app/billing/warnings/types";
import { SharedModule } from "../../../shared";
import { BillingAddress, getTaxIdTypeForCountry, selectableCountries, taxIdTypes } from "../types";
export interface BillingAddressControls {
country: string;
postalCode: string;
line1: string | null;
line2: string | null;
city: string | null;
state: string | null;
taxId: string | null;
}
export type BillingAddressFormGroup = FormGroup<ControlsOf<BillingAddressControls>>;
export const getBillingAddressFromForm = (formGroup: BillingAddressFormGroup): BillingAddress =>
getBillingAddressFromControls(formGroup.getRawValue());
export const getBillingAddressFromControls = (controls: BillingAddressControls) => {
const { taxId, ...addressFields } = controls;
const taxIdType = taxId ? getTaxIdTypeForCountry(addressFields.country) : null;
return taxIdType
? { ...addressFields, taxId: { code: taxIdType.code, value: taxId! } }
: { ...addressFields, taxId: null };
};
type Scenario =
| {
type: "checkout";
supportsTaxId: boolean;
}
| {
type: "update";
existing?: BillingAddress;
supportsTaxId: boolean;
taxIdWarning?: TaxIdWarningType;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-enter-billing-address",
template: `
<form [formGroup]="group">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "country" | i18n }}</bit-label>
<bit-select [formControl]="group.controls.country" data-testid="country">
@for (selectableCountry of selectableCountries; track selectableCountry.value) {
<bit-option
[value]="selectableCountry.value"
[disabled]="selectableCountry.disabled"
[label]="selectableCountry.name"
></bit-option>
}
</bit-select>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "zipPostalCodeLabel" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.postalCode"
autocomplete="postal-code"
data-testid="postal-code"
/>
</bit-form-field>
</div>
@if (scenario.type === "update") {
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "address1" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.line1"
autocomplete="address-line1"
data-testid="address-line1"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "address2" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.line2"
autocomplete="address-line2"
data-testid="address-line2"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "cityTown" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.city"
autocomplete="address-level2"
data-testid="city"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "stateProvince" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.state"
autocomplete="address-level1"
data-testid="state"
/>
</bit-form-field>
</div>
}
@if (supportsTaxId$ | async) {
<div class="tw-col-span-12">
<bit-form-field [disableMargin]="true">
<bit-label>{{ "taxIdNumber" | i18n }}</bit-label>
<input
bitInput
type="text"
[formControl]="group.controls.taxId"
data-testid="tax-id"
/>
@let hint = taxIdWarningHint;
@if (hint) {
<bit-hint
><i
class="bwi bwi-exclamation-triangle tw-mr-1"
title="{{ hint }}"
aria-hidden="true"
></i
>{{ hint }}</bit-hint
>
}
</bit-form-field>
</div>
}
</div>
</form>
`,
standalone: true,
imports: [SharedModule],
})
export class EnterBillingAddressComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) scenario!: Scenario;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) group!: BillingAddressFormGroup;
protected selectableCountries = selectableCountries;
protected supportsTaxId$!: Observable<boolean>;
private destroy$ = new Subject<void>();
constructor(private i18nService: I18nService) {}
ngOnInit() {
switch (this.scenario.type) {
case "checkout": {
this.disableAddressControls();
break;
}
case "update": {
if (this.scenario.existing) {
this.group.patchValue({
...this.scenario.existing,
taxId: this.scenario.existing.taxId?.value,
});
}
}
}
this.supportsTaxId$ = this.group.controls.country.valueChanges.pipe(
startWith(this.group.value.country ?? this.selectableCountries[0].value),
map((country) => {
if (!this.scenario.supportsTaxId || country === "US") {
return false;
}
return taxIdTypes.filter((taxIdType) => taxIdType.iso === country).length > 0;
}),
);
this.supportsTaxId$.pipe(takeUntil(this.destroy$)).subscribe((supportsTaxId) => {
if (supportsTaxId) {
this.group.controls.taxId.enable();
} else {
this.group.controls.taxId.disable();
}
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
disableAddressControls = () => {
this.group.controls.line1.disable();
this.group.controls.line2.disable();
this.group.controls.city.disable();
this.group.controls.state.disable();
};
get taxIdWarningHint() {
if (
this.scenario.type === "checkout" ||
!this.scenario.supportsTaxId ||
!this.group.value.country ||
this.scenario.taxIdWarning !== TaxIdWarningTypes.FailedVerification
) {
return null;
}
const taxIdType = getTaxIdTypeForCountry(this.group.value.country);
if (!taxIdType) {
return null;
}
const checkInputFormat = this.i18nService.t("checkInputFormat");
switch (taxIdType.code) {
case "au_abn": {
const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "ABN", taxIdType.example);
return `${checkInputFormat} ${exampleFormat}`;
}
case "eu_vat": {
const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "EU VAT", taxIdType.example);
return `${checkInputFormat} ${exampleFormat}`;
}
case "gb_vat": {
const exampleFormat = this.i18nService.t("exampleTaxIdFormat", "GB VAT", taxIdType.example);
return `${checkInputFormat} ${exampleFormat}`;
}
}
}
static getFormGroup = (): BillingAddressFormGroup =>
new FormGroup({
country: new FormControl<string>("", {
nonNullable: true,
validators: [Validators.required],
}),
postalCode: new FormControl<string>("", {
nonNullable: true,
validators: [Validators.required],
}),
line1: new FormControl<string | null>(null),
line2: new FormControl<string | null>(null),
city: new FormControl<string | null>(null),
state: new FormControl<string | null>(null),
taxId: new FormControl<string | null>(null),
});
}