Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/Donations/DonationPaymentDetailsViewController.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import AuthenticationServices
import SignalServiceKit
import SignalUI

class DonationPaymentDetailsViewController: OWSTableViewController2 {
    enum IDEALPaymentType {
        case oneTime
        case recurring(mandate: Stripe.PaymentMethod.Mandate)
    }

    enum PaymentMethod {
        case card
        case sepa(mandate: Stripe.PaymentMethod.Mandate)
        case ideal(paymentType: IDEALPaymentType)

        fileprivate var stripePaymentMethod: OWSRequestFactory.StripePaymentMethod {
            switch self {
            case .card:
                return .card
            case .sepa, .ideal:
                return .bankTransfer(.sepa)
            }
        }
    }

    let donationAmount: FiatMoney
    let donationMode: DonationMode
    let paymentMethod: PaymentMethod
    let onFinished: (Error?) -> Void
    var threeDSecureAuthenticationSession: ASWebAuthenticationSession?
    var threeDSecureAuthenticationFuture: Future<String>?

    override var preferredNavigationBarStyle: OWSNavigationBarStyle { .solid }
    override var navbarBackgroundColorOverride: UIColor? { tableBackgroundColor }

    init(
        donationAmount: FiatMoney,
        donationMode: DonationMode,
        paymentMethod: PaymentMethod,
        onFinished: @escaping (Error?) -> Void,
    ) {
        self.donationAmount = donationAmount
        self.donationMode = donationMode
        self.paymentMethod = paymentMethod
        self.onFinished = onFinished

        super.init()

        switch paymentMethod {
        case .card:
            title = OWSLocalizedString(
                "PAYMENT_DETAILS_CARD_TITLE",
                comment: "Header title for card payment details screen",
            )
        case .sepa, .ideal:
            title = OWSLocalizedString(
                "PAYMENT_DETAILS_BANK_TITLE",
                comment: "Header title for bank payment details screen",
            )
        }
    }

    deinit {
        threeDSecureAuthenticationSession?.cancel()
    }

    // MARK: - View callbacks

    override func viewDidLoad() {
        super.viewDidLoad()

        shouldAvoidKeyboard = true
        render()

        let sections = [donationAmountSection] + formSections()
        contents = OWSTableContents(sections: sections)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        switch paymentMethod {
        case .card:
            cardNumberView.becomeFirstResponder()
        case .sepa:
            ibanView.becomeFirstResponder()
        case .ideal:
            break
        }
    }

    // MARK: - Events

    private func didSubmit() {
        dismissKeyboard()

        switch formState {
        case .invalid, .potentiallyValid:
            owsFailDebug("[Donations] It should be impossible to submit the form without a fully-valid card. Is the submit button properly disabled?")
        case let .fullyValid(validForm):
            switch donationMode {
            case .oneTime:
                oneTimeDonation(with: validForm)
            case let .monthly(
                subscriptionLevel,
                subscriberID,
                _,
                currentSubscriptionLevel,
            ):
                monthlyDonation(
                    with: validForm,
                    newSubscriptionLevel: subscriptionLevel,
                    priorSubscriptionLevel: currentSubscriptionLevel,
                    subscriberID: subscriberID,
                )
            case let .gift(thread, messageText):
                switch validForm {
                case let .card(creditOrDebitCard):
                    giftDonation(with: creditOrDebitCard, in: thread, messageText: messageText)
                case .sepa, .ideal:
                    owsFailDebug("Gift badges do not support bank transfers")
                }
            }
        }
    }

    private func dismissKeyboard() {
        [
            cardNumberView,
            expirationView,
            cvvView,
            ibanView,
            nameView,
            emailView,
        ].forEach { formFieldView in
            formFieldView.resignFirstResponder()
        }
    }

    // MARK: - Rendering

    private func render() {
        // We'd like a link that doesn't go anywhere, because we'd like to
        // handle the tapping ourselves. We use a "fake" URL because BonMot
        // needs one.
        let linkPart = StringStyle.Part.link(URL.Support.Donations.subscriptionFAQ)

        let subheaderText: String
        switch self.paymentMethod {
        case .card:
            subheaderText = OWSLocalizedString(
                "CARD_DONATION_SUBHEADER_TEXT",
                comment: "On the credit/debit card donation screen, a small amount of information text is shown. This is that text. It should (1) instruct users to enter their credit/debit card information (2) tell them that Signal does not collect or store their personal information.",
            )
        case .sepa, .ideal:
            subheaderText = OWSLocalizedString(
                "BANK_DONATION_SUBHEADER_TEXT",
                comment: "On the bank transfer donation screen, a small amount of information text is shown. This is that text. It should (1) instruct users to enter their bank information (2) tell them that Signal does not collect or store their personal information.",
            )
        }

        subheaderTextView.attributedText = .composed(of: [
            subheaderText,
            " ",
            OWSLocalizedString(
                "CARD_DONATION_SUBHEADER_LEARN_MORE",
                comment: "On the credit/debit card donation screen, a small amount of information text is shown. Users can click this link to learn more information.",
            ).styled(with: linkPart),
        ]).styled(with: .color(.Signal.secondaryLabel), .font(.dynamicTypeFootnoteClamped))
        subheaderTextView.linkTextAttributes = [.foregroundColor: UIColor.Signal.label]

        // Only change the placeholder when enough digits are entered.
        // Helps avoid a jittery UI as you type/delete.
        let rawNumber = cardNumberView.text
        let cardType = CreditAndDebitCards.cardType(ofNumber: rawNumber)
        if rawNumber.count >= 2 {
            cvvView.placeholder = String("1234".prefix(cardType.cvvCount))
        }

        let invalidFields: Set<InvalidFormField>
        switch formState {
        case let .invalid(fields):
            invalidFields = fields
            submitButton.isEnabled = false
        case .potentiallyValid:
            invalidFields = []
            submitButton.isEnabled = false
        case .fullyValid:
            invalidFields = []
            submitButton.isEnabled = true
        }

        tableView.beginUpdates()
        cardNumberView.render(errorMessage: {
            guard invalidFields.contains(.cardNumber) else { return nil }
            return OWSLocalizedString(
                "CARD_DONATION_CARD_NUMBER_GENERIC_ERROR",
                comment: "Users can donate to Signal with a credit or debit card. If their card number is invalid, this generic error message will be shown. Try to use a short string to make space in the UI.",
            )
        }())
        expirationView.render(errorMessage: {
            guard invalidFields.contains(.expirationDate) else { return nil }
            return OWSLocalizedString(
                "CARD_DONATION_EXPIRATION_DATE_GENERIC_ERROR",
                comment: "Users can donate to Signal with a credit or debit card. If their expiration date is invalid, this generic error message will be shown. Try to use a short string to make space in the UI.",
            )
        }())
        cvvView.render(errorMessage: {
            guard invalidFields.contains(.cvv) else { return nil }
            if cvvView.text.count > cardType.cvvCount {
                return OWSLocalizedString(
                    "CARD_DONATION_CVV_TOO_LONG_ERROR",
                    comment: "Users can donate to Signal with a credit or debit card. If their card verification code (CVV) is too long, this error will be shown. Try to use a short string to make space in the UI.",
                )
            } else {
                return OWSLocalizedString(
                    "CARD_DONATION_CVV_GENERIC_ERROR",
                    comment: "Users can donate to Signal with a credit or debit card. If their card verification code (CVV) is invalid for reasons we cannot determine, this generic error message will be shown. Try to use a short string to make space in the UI.",
                )
            }
        }())
        ibanView.render(errorMessage: ibanErrorMessage(invalidFields: invalidFields))
        // Currently, name and email can only be valid or potentially
        // valid. There is no invalid state for either.
        tableView.endUpdates()
    }

    private func ibanErrorMessage(invalidFields: Set<InvalidFormField>) -> String? {
        invalidFields.lazy
            .compactMap { field -> SEPABankAccounts.IBANInvalidity? in
                guard case let .iban(invalidity) = field else { return nil }
                return invalidity
            }
            .first
            .map { invalidity in
                switch invalidity {
                case .invalidCharacters:
                    return OWSLocalizedString(
                        "SEPA_DONATION_IBAN_INVALID_CHARACTERS_ERROR",
                        comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) contains characters other than letters and numbers, this error will be shown. Try to use a short string to make space in the UI.",
                    )
                case .invalidCheck:
                    return OWSLocalizedString(
                        "SEPA_DONATION_IBAN_INVALID_CHECK_ERROR",
                        comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) does not pass validation, this error will be shown. Try to use a short string to make space in the UI.",
                    )
                case .invalidCountry:
                    return OWSLocalizedString(
                        "SEPA_DONATION_IBAN_INVALID_COUNTRY_ERROR",
                        comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) has an unsupported country code, this error will be shown. Try to use a short string to make space in the UI.",
                    )
                case .tooLong:
                    return OWSLocalizedString(
                        "SEPA_DONATION_IBAN_TOO_LONG_ERROR",
                        comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) is too long, this error will be shown. Try to use a short string to make space in the UI.",
                    )
                case .tooShort:
                    return OWSLocalizedString(
                        "SEPA_DONATION_IBAN_TOO_SHORT_ERROR",
                        comment: "Users can donate to Signal with a bank account. If their internation bank account number (IBAN) is too long, this error will be shown. Try to use a short string to make space in the UI.",
                    )
                }
            }
    }

    // MARK: - Donation amount section

    private lazy var subheaderTextView: LinkingTextView = {
        let result = LinkingTextView()
        result.delegate = self
        return result
    }()

    private lazy var donationAmountSection: OWSTableSection = {
        let result = OWSTableSection(
            items: [.init(
                customCellBlock: { [weak self] in
                    let cell = OWSTableItem.newCell()
                    cell.selectionStyle = .none
                    guard let self else { return cell }
                    cell.contentView.addSubview(self.subheaderTextView)
                    self.subheaderTextView.autoPinEdgesToSuperviewMargins()
                    return cell
                },
            )],
        )
        result.hasBackground = false
        return result
    }()

    // MARK: - Form

    private var formState: FormState {
        switch self.paymentMethod {
        case .card:
            return Self.formState(
                cardNumber: cardNumberView.text,
                isCardNumberFieldFocused: cardNumberView.isFirstResponder,
                expirationDate: expirationView.text,
                cvv: cvvView.text,
            )
        case let .sepa(mandate: mandate):
            return Self.formState(
                mandate: mandate,
                iban: ibanView.text,
                isIBANFieldFocused: ibanView.isFirstResponder,
                name: nameView.text,
                email: emailView.text,
                isEmailFieldFocused: emailView.isFirstResponder,
            )
        case let .ideal(paymentType):
            return Self.formState(
                IDEALPaymentType: paymentType,
                name: nameView.text,
                email: emailView.text,
                isEmailFieldFocused: emailView.isFirstResponder,
            )
        }
    }

    private func formSections() -> [OWSTableSection] {
        switch self.paymentMethod {
        case .card:
            return [creditCardFormSection]
        case .sepa:
            return [sepaFormSection]
        case .ideal:
            return idealFormSections()
        }
    }

    private static func cell(for formFieldView: FormFieldView) -> OWSTableItem {
        .init(customCellBlock: { [weak formFieldView] in
            let cell = OWSTableItem.newCell()
            cell.selectionStyle = .none
            guard let formFieldView else { return cell }
            cell.contentView.addSubview(formFieldView)
            formFieldView.autoPinEdgesToSuperviewMargins()
            return cell
        })
    }

    // MARK: Form field title strings

    private static let cardNumberTitle = OWSLocalizedString(
        "CARD_DONATION_CARD_NUMBER_LABEL",
        comment: "Users can donate to Signal with a credit or debit card. This is the label for the card number field on that screen.",
    )

    private static let cardNumberPlaceholder = "0000000000000000"

    private static let expirationTitle = OWSLocalizedString(
        "CARD_DONATION_EXPIRATION_DATE_LABEL",
        comment: "Users can donate to Signal with a credit or debit card. This is the label for the expiration date field on that screen. Try to use a short string to make space in the UI. (For example, the English text uses \"Exp. Date\" instead of \"Expiration Date\").",
    )

    private static let cvvTitle = OWSLocalizedString(
        "CARD_DONATION_CVV_LABEL",
        comment: "Users can donate to Signal with a credit or debit card. This is the label for the card verification code (CVV) field on that screen.",
    )

    private static let ibanTitle = OWSLocalizedString(
        "SEPA_DONATION_IBAN_LABEL",
        comment: "Users can donate to Signal with a bank account. This is the label for IBAN (internation bank account number) field on that screen.",
    )

    private static let ibanPlaceholder = "DE00000000000000000000"

    private static let nameTitle = OWSLocalizedString(
        "SEPA_DONATION_NAME_LABEL",
        comment: "Users can donate to Signal with a bank account. This is the label for name field on that screen.",
    )

    private static let emailTitle = OWSLocalizedString(
        "SEPA_DONATION_EMAIL_LABEL",
        comment: "Users can donate to Signal with a bank account. This is the label for email field on that screen.",
    )

    // MARK: Form field title styles

    private lazy var cardFormTitleLayout: FormFieldView.TitleLayout = titleLayout(
        for: [
            Self.cardNumberTitle,
            Self.expirationTitle,
            Self.cvvTitle,
        ],
        titleWidth: 120,
        placeholder: Self.formatCardNumber(unformatted: Self.cardNumberPlaceholder),
    )

    private lazy var sepaFormTitleLayout: FormFieldView.TitleLayout = titleLayout(
        for: [
            Self.ibanTitle,
            Self.nameTitle,
            Self.emailTitle,
        ],
        titleWidth: 60,
        placeholder: Self.formatIBAN(unformatted: Self.ibanPlaceholder),
    )

    private func titleLayout(for titles: [String], titleWidth: CGFloat, placeholder: String) -> FormFieldView.TitleLayout {
        guard
            Self.canTitlesFitInWidth(titles: titles, width: titleWidth),
            self.canPlaceholderFitInAvailableWidth(
                placeholder: placeholder,
                headerWidth: titleWidth,
            )
        else { return .compact }

        return .inline(width: titleWidth)
    }

    private static func canTitlesFitInWidth(titles: [String], width: CGFloat) -> Bool {
        titles.allSatisfy { title in
            FormFieldView.titleAttributedString(title).size().width <= width
        }
    }

    private func canPlaceholderFitInAvailableWidth(placeholder: String, headerWidth: CGFloat) -> Bool {
        let placeholderTextWidth = NSAttributedString(string: placeholder, attributes: [.font: FormFieldView.textFieldFont]).size().width
        let insets = self.cellOuterInsets.totalWidth + Self.cellHInnerMargin * 2
        let totalWidth = placeholderTextWidth + insets + headerWidth + FormFieldView.titleSpacing
        return totalWidth <= self.view.width
    }

    // MARK: - Card form

    private lazy var creditCardFormSection = OWSTableSection(items: [
        Self.cell(for: self.cardNumberView),
        Self.cell(for: self.expirationView),
        Self.cell(for: self.cvvView),
    ])

    // MARK: Card number

    nonisolated static func formatCardNumber(unformatted: String) -> String {
        var gaps: Set<Int>
        switch CreditAndDebitCards.cardType(ofNumber: unformatted) {
        case .americanExpress: gaps = [4, 10]
        case .unionPay, .other: gaps = [4, 8, 12]
        }

        var result = [Character]()
        for (i, character) in unformatted.enumerated() {
            if gaps.contains(i) {
                result.append(" ")
            }
            result.append(character)
        }
        if gaps.contains(unformatted.count) {
            result.append(" ")
        }
        return String(result)
    }

    private lazy var cardNumberView = FormFieldView(
        title: Self.cardNumberTitle,
        titleLayout: self.cardFormTitleLayout,
        placeholder: Self.formatCardNumber(unformatted: Self.cardNumberPlaceholder),
        style: .formatted(
            format: Self.formatCardNumber(unformatted:),
            allowedCharacters: .numbers,
            maxDigits: 19,
        ),
        textContentType: .creditCardNumber,
        delegate: self,
    )

    // MARK: Expiration date

    nonisolated static func formatExpirationDate(unformatted: String) -> String {
        switch unformatted.count {
        case 0:
            return unformatted
        case 1:
            let firstDigit = unformatted.first!
            switch firstDigit {
            case "0", "1": return unformatted
            default: return unformatted + "/"
            }
        case 2:
            if (UInt8(unformatted) ?? 0).isValidAsMonth {
                return unformatted + "/"
            } else {
                return "\(unformatted.prefix(1))/\(unformatted.suffix(1))"
            }
        default:
            let firstTwo = unformatted.prefix(2)
            let firstTwoAsMonth = UInt8(String(firstTwo)) ?? 0
            let monthCount = firstTwoAsMonth.isValidAsMonth ? 2 : 1
            let month = unformatted.prefix(monthCount)
            let year = unformatted.suffix(unformatted.count - monthCount)
            return "\(month)/\(year)"
        }
    }

    private lazy var expirationView = {
        let textContentType: UITextContentType?
        if #available(iOS 17.0, *) {
            textContentType = .creditCardExpiration
        } else {
            textContentType = nil
        }
        return FormFieldView(
            title: Self.expirationTitle,
            titleLayout: self.cardFormTitleLayout,
            placeholder: OWSLocalizedString(
                "CARD_DONATION_EXPIRATION_DATE_PLACEHOLDER",
                comment: "Users can donate to Signal with a credit or debit card. This is the label for the card expiration date field on that screen.",
            ),
            style: .formatted(
                format: Self.formatExpirationDate(unformatted:),
                allowedCharacters: .numbers,
                maxDigits: 4,
            ),
            textContentType: textContentType,
            delegate: self,
        )
    }()

    // MARK: CVV

    private lazy var cvvView = {
        let textContentType: UITextContentType?
        if #available(iOS 17.0, *) {
            textContentType = .creditCardSecurityCode
        } else {
            textContentType = nil
        }
        return FormFieldView(
            title: Self.cvvTitle,
            titleLayout: self.cardFormTitleLayout,
            placeholder: "123",
            style: .formatted(
                format: { $0 },
                allowedCharacters: .numbers,
                maxDigits: 4,
            ),
            textContentType: textContentType,
            delegate: self,
        )
    }()

    // MARK: - SEPA form

    private lazy var sepaFormSection = {
        let section = OWSTableSection(items: [
            Self.cell(for: self.ibanView),
            Self.cell(for: self.nameView),
            Self.cell(for: self.emailView),
        ])

        let findAccountInfoButton = UIButton(
            configuration: .mediumBorderless(title: OWSLocalizedString(
                "BANK_DONATION_FOOTER_FIND_ACCOUNT_INFO",
                comment: "On the bank donation screen, show a link below the input form to show help about finding account info.",
            )),
            primaryAction: UIAction { [weak self] _ in
                self?.present(DonationPaymentDetailsFindAccountInfoSheetViewController(), animated: true)
            },
        )
        let stackView = UIStackView.verticalButtonStack(buttons: [findAccountInfoButton])
        stackView.directionalLayoutMargins.top = 16

        section.customFooterView = stackView

        return section
    }()

    // MARK: IBAN

    private nonisolated static func formatIBAN(unformatted: String) -> String {
        let gaps: Set<Int> = [4, 8, 12, 16, 20, 24, 28, 32]

        var result = unformatted.enumerated().reduce(into: [Character]()) { partialResult, item in
            let (i, character) = item
            if gaps.contains(i) {
                partialResult.append(" ")
            }
            partialResult.append(character)
        }
        if gaps.contains(unformatted.count) {
            result.append(" ")
        }
        return String(result)
    }

    private lazy var ibanView: FormFieldView = FormFieldView(
        title: Self.ibanTitle,
        titleLayout: self.sepaFormTitleLayout,
        placeholder: Self.formatIBAN(unformatted: Self.ibanPlaceholder),
        style: .formatted(
            format: Self.formatIBAN(unformatted:),
            allowedCharacters: .alphanumeric,
            maxDigits: 34,
        ),
        textContentType: nil,
        delegate: self,
    )

    // MARK: iDEAL

    private func idealFormSections() -> [OWSTableSection] {
        let textViewItems: [OWSTableItem]
        switch self.donationMode {
        case .oneTime, .gift:
            textViewItems = [Self.cell(for: self.nameView)]
        case .monthly:
            textViewItems = [Self.cell(for: self.nameView), Self.cell(for: self.emailView)]
        }
        return [OWSTableSection(items: textViewItems)]
    }

    // MARK: Name & Email

    private lazy var nameView = FormFieldView(
        title: Self.nameTitle,
        titleLayout: self.sepaFormTitleLayout,
        placeholder: OWSLocalizedString(
            "SEPA_DONATION_NAME_PLACEHOLDER",
            comment: "Users can donate to Signal with a bank account. This is placeholder text for the name field before the user starts typing.",
        ),
        style: .plain(keyboardType: .default),
        textContentType: .name,
        delegate: self,
    )

    private lazy var emailView = FormFieldView(
        title: Self.emailTitle,
        titleLayout: self.sepaFormTitleLayout,
        placeholder: OWSLocalizedString(
            "SEPA_DONATION_EMAIL_PLACEHOLDER",
            comment: "Users can donate to Signal with a bank account. This is placeholder text for the email field before the user starts typing.",
        ),
        style: .plain(keyboardType: .emailAddress),
        textContentType: .emailAddress,
        delegate: self,
    )

    // MARK: - Submit button, footer

    private lazy var submitButton: UIButton = {
        let amountString = CurrencyFormatter.format(money: self.donationAmount)
        let title = {
            let format: String
            switch self.donationMode {
            case .oneTime, .gift:
                format = OWSLocalizedString(
                    "DONATE_BUTTON",
                    comment: "Users can donate to Signal with a credit or debit card. This is the heading on that screen, telling them how much they'll donate. Embeds {{formatted amount of money}}, such as \"$20\".",
                )
            case .monthly:
                format = OWSLocalizedString(
                    "DONATE_BUTTON_MONTHLY",
                    comment: "Users can donate to Signal with a credit or debit card. This is the heading on that screen, telling them how much they'll donate every month. Embeds {{formatted amount of money}}, such as \"$20\".",
                )
            }
            return String.nonPluralLocalizedStringWithFormat(format, amountString)
        }()

        return UIButton(
            configuration: .largePrimary(title: title),
            primaryAction: UIAction { [weak self] _ in
                guard let self else { return }
                let submitAction = {
                    self.didSubmit()
                }
                switch self.paymentMethod {
                case .card, .sepa:
                    submitAction()
                case .ideal:
                    switch self.donationMode {
                    case .oneTime, .gift:
                        submitAction()
                    case .monthly:
                        let title = OWSLocalizedString(
                            "IDEAL_DONATION_CONFIRM_DONATION_TITLE",
                            comment: "Fallback title confirming recurring donation with bank.",
                        )

                        let messageFormat = OWSLocalizedString(
                            "IDEAL_DONATION_CONFIRM_DONATION_WITH_BANK_MESSAGE",
                            comment: "Message confirming recurring donation with bank. This message confirms with the user that they will see a small confirmation charge with their bank before the donation. Embeds 1:{{ 0.01 euro, as a localized string }}, 2:{{ the amount of their donation, as a localized string }}.",
                        )
                        let oneEuroCentString = CurrencyFormatter.format(
                            money: FiatMoney(currencyCode: "EUR", value: 0.01),
                        )
                        let message = String.nonPluralLocalizedStringWithFormat(messageFormat, oneEuroCentString, amountString)

                        let actionSheet = ActionSheetController(title: title, message: message)
                        actionSheet.addAction(.init(
                            title: CommonStrings.continueButton,
                            style: .default,
                            handler: { _ in
                                submitAction()
                            },
                        ))
                        actionSheet.addAction(.init(
                            title: CommonStrings.cancelButton,
                            style: .cancel,
                            handler: nil,
                        ))
                        self.presentActionSheet(actionSheet)
                    }
                }
            },
        )
    }()

    private lazy var bottomFooterContainer: UIView = {
        let stackView = UIStackView.verticalButtonStack(buttons: [submitButton])
        let view = UIView()
        view.preservesSuperviewLayoutMargins = true
        view.addSubview(stackView)
        view.backgroundColor = .Signal.groupedBackground
        stackView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            stackView.topAnchor.constraint(equalTo: view.topAnchor),
            stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
            stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
            stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
        return view
    }()

    override open var bottomFooter: UIView? {
        get { bottomFooterContainer }
        set {}
    }
}

// MARK: - UITextViewDelegate

extension DonationPaymentDetailsViewController: UITextViewDelegate {
    func textView(
        _ textView: UITextView,
        shouldInteractWith URL: URL,
        in characterRange: NSRange,
        interaction: UITextItemInteraction,
    ) -> Bool {
        present(DonationPaymentDetailsReadMoreSheetViewController(), animated: true)
        return false
    }
}

// MARK: - CreditOrDebitCardDonationFormViewDelegate

extension DonationPaymentDetailsViewController: CreditOrDebitCardDonationFormViewDelegate {
    func didSomethingChange() { render() }
}

// MARK: - Utilities

private extension UInt8 {
    var isValidAsMonth: Bool { self >= 1 && self <= 12 }
}

// MARK: - Previews

#if DEBUG
@available(iOS 17, *)
#Preview {
    DonationPaymentDetailsViewController(
        donationAmount: .init(currencyCode: "USD", value: 10),
        donationMode: .oneTime,
        paymentMethod: .card,
        onFinished: { _ in },
    )
}
#endif