Как давно вы платили на веб-сайте в один клик с помощью Google Pay, Apple Pay или заранее заданной в браузере картой?
У меня такое получается редко.
Даже наоборот: каждый новый интернет-магазин предлагает мне очередную формочку. А я должен каждый раз покорно искать свою карту, чтобы перепечатать данные с нее на сайт. На следующий день я захочу оплатить что-нибудь в другом магазине и повторю этот процесс.
Это не очень удобно. Особенно когда знаешь об альтернативе: в последние пару лет стандарт Payment Request API позволяет легко решать эту проблему в современных браузерах.
Давайте разберемся, почему его не используют, и попробуем упростить работу с ним.
О чем речь?
Почти во всех современных браузерах реализован стандарт Payment Request API. Он позволяет вызвать модальное окно в браузере, через которое пользователь сможет провести платеж в считанные секунды. Вот так это может выглядеть в Chrome с обычной карточкой из браузера:
А вот так — в Safari при оплате отпечатком пальца через Apple Pay:
Это не только быстро, но и функционально: окно позволяет выводить информацию по всему заказу и по отдельным товарам и услугам внутри него, позволяет уточнить информацию о клиенте и детали по доставке. Все это кастомизируется при создании запроса, хотя и удобство у предоставляемого API довольно спорное.
Как использовать в Angular?
Angular не предоставляет абстракций для использования Payment Request API. Самый безопасный путь использования из коробки в Angular: достать Document из механизма Dependency Injection, получить из него объект Window и работать с window.PaymentRequest.
import {DOCUMENT} from '@angular/common';
import {Inject, Injectable} from '@angular/core';
@Injectable()
export class PaymentService {
constructor(
@Inject(DOCUMENT)
private readonly documentRef: Document,
) {}
pay(
methodData: PaymentMethodData[],
details: PaymentDetailsInit,
options: PaymentOptions = {},
): Promise<PaymentResponse> {
if (
this.documentRef.defaultView === null ||
!('PaymentRequest' in this.documentRef.defaultView)
) {
return Promise.reject(new Error('PaymentRequest is not supported'));
}
const gateway = new PaymentRequest(methodData, details, options);
return gateway
.canMakePayment()
.then(canPay =>
canPay
? gateway.show()
: Promise.reject(
new Error('Payment Request cannot make the payment'),
),
);
}
}
Если использовать Payment Request напрямую, то появляются все проблемы неявных зависимостей: тестировать код становится тяжелее, в SSR приложение взрывается, потому что Payment Request не существует. Надеемся на глобальный объект без каких-либо абстракций.
Мы можем взять токен WINDOW из @ng-web-apis/common, чтобы безопасно получить глобальный объект из DI. Теперь добавим новый PAYMENT_REQUEST_SUPPORT. Он позволит проверять поддержку Payment Request API перед его использованием, и теперь у нас никогда не произойдет случайного вызова API в среде, которая его не поддерживает.
export const PAYMENT_REQUEST_SUPPORT = new InjectionToken<boolean>(
'Is Payment Request Api supported?',
{
factory: () => !!inject(WINDOW).PaymentRequest,
},
);
export class PaymentRequestService {
constructor(
@Inject(PAYMENT_REQUEST_SUPPORT) private readonly supported: boolean,
...
) {}
request(...): Promise<PaymentResponse> {
if (!this.supported) {
return Promise.reject(
new Error('Payment Request is not supported in your browser'),
);
}
...
}
Давайте писать в стиле Angular
С описанным выше подходом мы можем достаточно безопасно работать с платежами, но удобство работы все еще остается на том же уровне «голого» API-браузера: мы вызываем метод с тремя параметрами, собираем множество данных воедино и приводим их к нужному формату, чтобы наконец вызвать метод платежа.
Но в мире Ангуляра мы привыкли к удобным абстракциям: механизму внедрения зависимостей, сервисам, директивам и стримам. Давайте посмотрим на декларативное решение, которое позволяет сделать использование Payment Request API быстрее и проще:
В этом примере корзина представляет собой вот такой код:
<div waPayment [paymentTotal]="total">
<div
*ngFor="let cartItem of shippingCart"
waPaymentItem
[paymentLabel]="cartItem.label"
[paymentAmount]="cartItem.amount"
>
{{ cartItem.label }} ({{ cartItem.amount.value }} {{ cartItem.amount.currency }})
</div>
<b>Total:</b> {{ totalSum }} ₽
<button
[disabled]="shippingCart.length === 0"
(waPaymentSubmit)="onPayment($event)"
(waPaymentError)="onPaymentError($event)"
>
Buy
</button>
</div>
Все работает благодаря трем директивам:
- waPayment директива, которая определяет область отдельного платежа в шаблоне и принимает в себя объект PaymentItem с информацией о названии платежа и его итоговой суммой
- Каждый товар в корзине — директива waPaymentItem. Инпуты этой директивы позволяют собрать объект PaymentItem каждого отдельного товара декларативно.
- Нажатие на кнопку запускает модальное окно Payment Request API в браузере. Ответом модального окна может быть PaymentResponse или ошибка. Директива waPaymentSubmit позволяет отлавливать оба этих исхода обычными ангуляровскими аутпутами.
Так мы получаем простой и удобный интерфейс для открытия платежа и обработки его результата. Причем работает он по всем канонам Angular Way.
Сами директивы связаны довольно простым образом:
- Директива платежа собирает все товары внутри себя с помощью ContentChildren и имплементирует PaymentDetailsInit — один из обязательных аргументов при работе с Payment Request API.
@Directive({
selector: '[waPayment][paymentTotal]',
})
export class PaymentDirective implements PaymentDetailsInit {
...
@ContentChildren(PaymentItemDirective)
set paymentItems(items: QueryList<PaymentItem>) {
this.displayItems = items.toArray();
}
displayItems?: PaymentItem[];
}
- Директива-аутпут, которая отслеживает клики по кнопке и эмитит итоговый результат платежа, вытаскивает директиву платежа из дерева Dependency Injection, а также методы платежей и дополнительные опции, которые задаются DI-токенами.
@Directive({
selector: '[waPaymentSubmit]',
})
export class PaymentSubmitDirective {
@Output()
waPaymentSubmit: Observable<PaymentResponse>;
@Output()
waPaymentError: Observable<Error | DOMException>;
constructor(
@Inject(PaymentDirective) paymentHost: PaymentDetailsInit,
@Inject(PaymentRequestService) paymentRequest: PaymentRequestService,
@Inject(ElementRef) {nativeElement}: ElementRef,
@Inject(PAYMENT_METHODS) methods: PaymentMethodData[],
@Inject(PAYMENT_OPTIONS) options: PaymentOptions,
) {
const requests$ = fromEvent(nativeElement, 'click').pipe(
switchMap(() =>
from(paymentRequest.request({...paymentHost}, methods, options)).pipe(
catchError(error => of(error)),
),
),
share(),
);
this.waPaymentSubmit = requests$.pipe(filter(response => !isError(response)));
this.waPaymentError = requests$.pipe(filter(isError));
}
}
Готовое решение
Все описанные идеи мы собрали и реализовали в библиотеке @ng-web-apis/payment-request:
- Репозиторий с кодом на GitHub.
- Демопример, с которого сделаны скриншоты и гифки в этой статье.
Это готовое решение, которое позволяет работать с Payment Request API безопасно и быстро как через сервис, так и через директивы в описанном выше формате.
Эту библиотеку мы опубликовали и поддерживаем от @ng-web-apis — опенсорсной группы, специализирующейся на реализации легких Angular-оберток для нативных Web API, преимущественно в декларативном стиле. На нашем сайте есть и другие реализации API, которые не поставляются в Angular из коробки, но могут заинтересовать вас.
Автор: MarsiBarsi