diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2a0508ee1b..a03e15cb2f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8886,6 +8886,50 @@ "messageformat": "Having issues? Contact support", "description": "In the Donations settings section, this footer text appears during parts of the donation workflow such as when picking a donation currency and amount, or entering the credit card info." }, + "icu:DonateFlow__credit-or-debit-card": { + "messageformat": "Credit or Debit Card", + "description": "When entering payment card details for a donation, this heading indicates the section for credit or debit cards." + }, + "icu:DonateFlow__card-form-instructions": { + "messageformat": "Enter your credit or debit card details. Signal does not collect or store your personal information. Learn more", + "description": "When entering payment card details for a donation, these instructions explain how to use the entry form and how payment information is protected." + }, + "icu:DonateFlow__card-form-card-number": { + "messageformat": "Card Number", + "description": "When entering payment card details for a donation, this label is for the credit card account number input box." + }, + "icu:DonateFlow__card-form-expiration-date": { + "messageformat": "Expiration Date", + "description": "When entering payment card details for a donation, this label is for the credit card expiration date input box." + }, + "icu:DonateFlow__card-form-title-donate-with-amount": { + "messageformat": "Donate {formattedCurrencyAmount} to Signal", + "description": "Title above the payment card entry form after selecting a donation amount. Amount includes the currency symbol and is formatted in the locale's standard format. Examples: Donate $10; Donate ¥1000; Donate €10" + }, + "icu:DonateFlow__card-form-error-invalid-card-number": { + "messageformat": "Invalid card number", + "description": "When entering payment card details, this error appears when the card number was entered incorrectly." + }, + "icu:DonateFlow__card-form-error-expiration-expired": { + "messageformat": "Card has expired", + "description": "When entering payment card expiration date, this error appears when the card has expired." + }, + "icu:DonateFlow__card-form-error-year-missing": { + "messageformat": "Year required", + "description": "When entering payment card expiration date, this error appears when year was not entered but the user changed focus from the input box." + }, + "icu:DonateFlow__card-form-error-invalid": { + "messageformat": "Invalid", + "description": "When entering payment card details, this error appears when validation fails but we do not have a specific string for the failure case. For example, a card expiration date was mistyped with non-numeric digits." + }, + "icu:DonateFlow__card-form-error-cvc-too-short": { + "messageformat": "Code too short", + "description": "When entering payment card CVC/CVV code, this error appears when the code entered did not include all digits in the CVC/CVV code." + }, + "icu:DonateFlow__one-time-donation-boost-badge-info": { + "messageformat": "Get the Signal Boost badge for 30 days", + "description": "Info text above the payment card entry form when making a one time donation. Explains the in-app badge which you can receive after donating." + }, "icu:DonationReceipt__title": { "messageformat": "Donation receipt", "description": "Title shown at the top of donation receipt documents" diff --git a/images/rocket-160.svg b/images/rocket-160.svg new file mode 100644 index 0000000000..4dc3f96671 --- /dev/null +++ b/images/rocket-160.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/stylesheets/components/DonationForm.scss b/stylesheets/components/DonationForm.scss index bb37602b98..3c96916ab6 100644 --- a/stylesheets/components/DonationForm.scss +++ b/stylesheets/components/DonationForm.scss @@ -5,6 +5,8 @@ @use '../variables'; .DonationForm { + max-width: 439px; + align-self: center; text-align: center; } @@ -147,3 +149,110 @@ a.DonationFormHelpFooter__ContactSupportLink { margin-inline-end: 10px; text-align: end; } + +.DonationCardForm { + margin-inline: 3px; +} + +.DonationCardForm .DonationCardForm__Header--Info { + padding-inline: 0; + margin-block: 8px; + text-align: start; +} + +.DonationCardForm__Info { + @include mixins.font-body-2; + text-align: start; + margin-block-end: 26px; + color: light-dark( + variables.$color-black-alpha-50, + variables.$color-white-alpha-50 + ); + + &__read-more { + @include mixins.button-reset; + + & { + color: variables.$color-ultramarine; + } + + &:hover { + text-decoration: underline; + } + } +} + +.DonationCardForm_Field { + display: flex; + margin-block: 12px; +} + +.DonationCardForm_Field input { + @include mixins.font-body-2; + display: flex; + padding-block: 5px; + padding-inline: 12px 6px; + background-color: light-dark( + variables.$color-white, + variables.$color-gray-85 + ); + border-radius: 6px; + border: 0.5px solid rgba(0, 0, 0, 0.16); + box-shadow: 0 1px 1px 0 variables.$color-black-alpha-08; + outline-offset: -2.5px; +} + +.DonationCardForm_Field input:focus { + outline: 2.5px solid variables.$color-ultramarine-pastel; +} + +.DonationCardForm_Label, +.DonationCardForm_Field input { + color: light-dark( + variables.$color-black-alpha-85, + variables.$color-white-alpha-85 + ); +} + +.DonationCardForm_Label { + @include mixins.font-body-2; + display: flex; + flex-basis: 33%; + line-height: 20px; + padding-block: 5px; + margin-inline-end: 12px; + justify-content: flex-end; +} + +.DonationCardForm_InputContainer--with-error input { + outline: 2.5px solid variables.$color-deep-red; +} + +.DonationCardForm_FieldError { + @include mixins.font-caption; + margin-block-start: 6px; + margin-inline-start: 12px; + text-align: start; + color: variables.$color-deep-red; +} + +.DonationCardForm_CardNumberField input { + width: 196px; +} + +.DonationCardForm_CardExpirationField input, +.DonationCardForm_CardCvcField input { + width: 84px; +} + +.DonationCardForm__PrimaryButtonContainer { + margin-block-start: 20px; + text-align: end; +} + +.DonationCardFormHero__Badge { + width: 72px; + height: 72px; + background: url('../images/rocket-160.svg'); + background-size: 100%; +} diff --git a/stylesheets/components/PreferencesDonations.scss b/stylesheets/components/PreferencesDonations.scss index f03a76ff75..9d8ecbf46c 100644 --- a/stylesheets/components/PreferencesDonations.scss +++ b/stylesheets/components/PreferencesDonations.scss @@ -56,6 +56,10 @@ variables.$color-black-alpha-12, variables.$color-white-alpha-12 ); + + &--card-form { + margin-block: 20px 12px; + } } &__section-header { diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 6a80445bbb..33586e3872 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -218,7 +218,7 @@ function renderDonationsPane(props: { i18n={i18n} contentsRef={props.contentsRef} clearWorkflow={action('clearWorkflow')} - initialCurrency="USD" + initialCurrency="usd" resumeWorkflow={action('resumeWorkflow')} isStaging page={props.page} diff --git a/ts/components/PreferencesDonateFlow.tsx b/ts/components/PreferencesDonateFlow.tsx index 08c3b74495..8e1d52fad3 100644 --- a/ts/components/PreferencesDonateFlow.tsx +++ b/ts/components/PreferencesDonateFlow.tsx @@ -1,7 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { MutableRefObject } from 'react'; +import type { MutableRefObject, ReactNode } from 'react'; import React, { useCallback, useEffect, @@ -15,6 +15,7 @@ import type { LocalizerType } from '../types/Util'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { Button, ButtonVariant } from './Button'; import type { + CardDetail, DonationErrorType, HumanDonationAmount, } from '../types/Donations'; @@ -60,6 +61,7 @@ import { getCardCvcErrorMessage, } from './preferences/donations/DonateInputCardCvc'; import { I18n } from './I18n'; +import { strictAssert } from '../util/assert'; const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop'; @@ -79,6 +81,7 @@ type PropsHousekeepingType = { type PropsActionType = { clearWorkflow: () => void; + showPrivacyModal: () => void; submitDonation: (payload: SubmitDonationType) => void; onBack: () => void; }; @@ -95,6 +98,7 @@ export function PreferencesDonateFlow({ workflow, clearWorkflow, renderDonationHero, + showPrivacyModal, submitDonation, onBack, }: PropsType): JSX.Element { @@ -109,63 +113,10 @@ export function PreferencesDonateFlow({ const [amount, setAmount] = useState(); const [currency, setCurrency] = useState(initialCurrency); - const [cardExpiration, setCardExpiration] = useState(''); - const [cardNumber, setCardNumber] = useState(''); - const [cardCvc, setCardCvc] = useState(''); - const [isDonateDisabled, setIsDonateDisabled] = useState(false); - - const [cardNumberError, setCardNumberError] = - useState(null); - const [cardExpirationError, setCardExpirationError] = - useState(null); - const [cardCvcError, setCardCvcError] = useState(null); - - const possibleCardFormats = useMemo(() => { - return getPossibleCardFormats(cardNumber); - }, [cardNumber]); - const cardFormSettings = useMemo(() => { - return getCardFormSettings(possibleCardFormats); - }, [possibleCardFormats]); - - const handleCardNumberChange = useCallback((value: string) => { - setCardNumber(value); - setCardNumberError(null); - }, []); - - const handleCardNumberBlur = useCallback(() => { - if (cardNumber !== '') { - const result = parseCardNumber(cardNumber); - setCardNumberError(result.error ?? null); - } - }, [cardNumber]); - - const handleCardExpirationChange = useCallback((value: string) => { - setCardExpiration(value); - setCardExpirationError(null); - }, []); - - const handleCardExpirationBlur = useCallback(() => { - if (cardExpiration !== '') { - const result = parseCardExpiration(cardExpiration); - setCardExpirationError(result.error ?? null); - } - }, [cardExpiration]); - - const handleCardCvcChange = useCallback((value: string) => { - setCardCvc(value); - setCardCvcError(null); - }, []); - - const handleCardCvcBlur = useCallback(() => { - if (cardCvc !== '') { - const result = parseCardCvc(cardCvc, possibleCardFormats); - setCardCvcError(result.error ?? null); - } - }, [cardCvc, possibleCardFormats]); - - const formattedCurrencyAmount = useMemo(() => { - return toHumanCurrencyString({ amount, currency }); - }, [amount, currency]); + const [isCardFormDisabled, setIsCardFormDisabled] = useState(false); + const [cardFormValues, setCardFormValues] = useState< + CardFormValues | undefined + >(); const handleAmountPickerResult = useCallback((result: AmountPickerResult) => { const { currency: pickedCurrency, amount: pickedAmount } = result; @@ -174,62 +125,46 @@ export function PreferencesDonateFlow({ setStep('paymentDetails'); }, []); - const handleDonateClicked = useCallback(() => { - if (amount == null || currency == null) { - return; - } + const handleCardFormChanged = useCallback((values: CardFormValues) => { + setCardFormValues(values); + }, []); - const paymentAmount = toStripeDonationAmount({ amount, currency }); - const formResult = parseCardForm({ cardNumber, cardExpiration, cardCvc }); + const handleSubmitDonation = useCallback( + (cardDetail: CardDetail) => { + if (amount == null || currency == null) { + return; + } - setCardNumberError(formResult.cardNumber.error ?? null); - setCardExpirationError(formResult.cardExpiration.error ?? null); - setCardCvcError(formResult.cardCvc.error ?? null); + const paymentAmount = toStripeDonationAmount({ amount, currency }); - const cardDetail = cardFormToCardDetail(formResult); - if (cardDetail == null) { - return; - } - - setIsDonateDisabled(true); - submitDonation({ - currencyType: currency, - paymentAmount, - paymentDetail: cardDetail, - }); - }, [ - amount, - cardCvc, - cardExpiration, - cardNumber, - currency, - setIsDonateDisabled, - submitDonation, - ]); + setIsCardFormDisabled(true); + submitDonation({ + currencyType: currency, + paymentAmount, + paymentDetail: cardDetail, + }); + }, + [amount, currency, setIsCardFormDisabled, submitDonation] + ); useEffect(() => { if (!workflow || lastError) { - setIsDonateDisabled(false); + setIsCardFormDisabled(false); } - }, [lastError, setIsDonateDisabled, workflow]); + }, [lastError, setIsCardFormDisabled, workflow]); const onTryClose = useCallback(() => { const onDiscard = () => { clearWorkflow(); }; - const isDirty = Boolean( - (cardExpiration || cardNumber || cardCvc) && !isDonateDisabled + const isConfirmationNeeded = Boolean( + step === 'paymentDetails' && + !isCardFormDisabled && + workflow?.type !== 'DONE' ); - confirmDiscardIf(isDirty, onDiscard); - }, [ - cardCvc, - cardExpiration, - cardNumber, - clearWorkflow, - confirmDiscardIf, - isDonateDisabled, - ]); + confirmDiscardIf(isConfirmationNeeded, onDiscard); + }, [clearWorkflow, confirmDiscardIf, isCardFormDisabled, step, workflow]); tryClose.current = onTryClose; let innerContent: JSX.Element; @@ -253,66 +188,23 @@ export function PreferencesDonateFlow({ // Dismiss DonateFlow and return to Donations home handleBack = () => onBack(); } else { + strictAssert(amount, 'Amount is required for payment card form'); innerContent = ( -
- {workflow && ( -
-

Current Workflow

-
{JSON.stringify(workflow)}
- -
- )} - - -
-          {amount} {currency}
-        
- - + +
+ - {cardNumberError != null && ( - {getCardNumberErrorMessage(i18n, cardNumberError)} - )} - - - {cardExpirationError && ( - - {getCardExpirationErrorMessage(i18n, cardExpirationError)} - - )} - - - {cardCvcError && ( - {getCardCvcErrorMessage(i18n, cardCvcError)} - )} - -
+ + ); handleBack = () => { setStep('amount'); @@ -534,6 +426,268 @@ function AmountPicker({ ); } +type CardFormValues = { + cardExpiration: string | undefined; + cardNumber: string | undefined; + cardCvc: string | undefined; +}; + +type CardFormProps = { + amount: HumanDonationAmount; + currency: string; + disabled: boolean; + i18n: LocalizerType; + initialValues: CardFormValues | undefined; + onChange: (values: CardFormValues) => void; + onSubmit: (cardDetail: CardDetail) => void; + showPrivacyModal: () => void; +}; + +function CardForm({ + amount, + currency, + disabled, + i18n, + initialValues, + onChange, + onSubmit, + showPrivacyModal, +}: CardFormProps): JSX.Element { + const [cardExpiration, setCardExpiration] = useState( + initialValues?.cardExpiration ?? '' + ); + const [cardNumber, setCardNumber] = useState(initialValues?.cardNumber ?? ''); + const [cardCvc, setCardCvc] = useState(initialValues?.cardCvc ?? ''); + + const [cardNumberError, setCardNumberError] = + useState(null); + const [cardExpirationError, setCardExpirationError] = + useState(null); + const [cardCvcError, setCardCvcError] = useState(null); + + const possibleCardFormats = useMemo(() => { + return getPossibleCardFormats(cardNumber); + }, [cardNumber]); + const cardFormSettings = useMemo(() => { + return getCardFormSettings(possibleCardFormats); + }, [possibleCardFormats]); + + useEffect(() => { + onChange({ cardExpiration, cardNumber, cardCvc }); + }, [cardExpiration, cardNumber, cardCvc, onChange]); + + const privacyLearnMoreLink = useCallback( + (parts: ReactNode): JSX.Element => { + return ( + + ); + }, + [showPrivacyModal] + ); + + const handleCardNumberChange = useCallback((value: string) => { + setCardNumber(value); + setCardNumberError(null); + }, []); + + const handleCardNumberBlur = useCallback(() => { + if (cardNumber !== '') { + const result = parseCardNumber(cardNumber); + setCardNumberError(result.error ?? null); + } + }, [cardNumber]); + + const handleCardExpirationChange = useCallback((value: string) => { + setCardExpiration(value); + setCardExpirationError(null); + }, []); + + const handleCardExpirationBlur = useCallback(() => { + if (cardExpiration !== '') { + const result = parseCardExpiration(cardExpiration); + setCardExpirationError(result.error ?? null); + } + }, [cardExpiration]); + + const handleCardCvcChange = useCallback((value: string) => { + setCardCvc(value); + setCardCvcError(null); + }, []); + + const handleCardCvcBlur = useCallback(() => { + if (cardCvc !== '') { + const result = parseCardCvc(cardCvc, possibleCardFormats); + setCardCvcError(result.error ?? null); + } + }, [cardCvc, possibleCardFormats]); + + const formattedCurrencyAmount = useMemo(() => { + return toHumanCurrencyString({ amount, currency }); + }, [amount, currency]); + + const handleDonateClicked = useCallback(() => { + const formResult = parseCardForm({ cardNumber, cardExpiration, cardCvc }); + + setCardNumberError(formResult.cardNumber.error ?? null); + setCardExpirationError(formResult.cardExpiration.error ?? null); + setCardCvcError(formResult.cardCvc.error ?? null); + + const cardDetail = cardFormToCardDetail(formResult); + if (cardDetail == null) { + return; + } + + onSubmit(cardDetail); + }, [cardCvc, cardExpiration, cardNumber, onSubmit]); + + const isDonateDisabled = + disabled || + cardNumber === '' || + cardExpiration === '' || + cardCvc === '' || + cardNumberError != null || + cardExpirationError != null || + cardCvcError != null; + + return ( +
+
+ {i18n('icu:DonateFlow__credit-or-debit-card')} +
+
+ +
+
+ +
+ + {cardNumberError != null && ( +
+ {getCardNumberErrorMessage(i18n, cardNumberError)} +
+ )} +
+
+
+ +
+ + {cardExpirationError && ( +
+ {getCardExpirationErrorMessage(i18n, cardExpirationError)} +
+ )} +
+
+
+ +
+ + {cardCvcError && ( +
+ {getCardCvcErrorMessage(i18n, cardCvcError)} +
+ )} +
+
+
+ +
+
+ ); +} + +type CardFormHeroProps = { + amount: HumanDonationAmount; + currency: string; + i18n: LocalizerType; +}; + +// Similar to or renderDonationHero +function CardFormHero({ + amount, + currency, + i18n, +}: CardFormHeroProps): JSX.Element { + const formattedCurrencyAmount = useMemo(() => { + return toHumanCurrencyString({ amount, currency }); + }, [amount, currency]); + + return ( + <> +
+
+
+
+ {i18n('icu:DonateFlow__card-form-title-donate-with-amount', { + formattedCurrencyAmount, + })} +
+
+ {i18n('icu:DonateFlow__one-time-donation-boost-badge-info')} +
+ + ); +} + type HelpFooterProps = { i18n: LocalizerType; showOneTimeOnlyNotice?: boolean; diff --git a/ts/components/PreferencesDonations.tsx b/ts/components/PreferencesDonations.tsx index bfdf500966..39dad0ac25 100644 --- a/ts/components/PreferencesDonations.tsx +++ b/ts/components/PreferencesDonations.tsx @@ -111,7 +111,9 @@ function isDonationPage(page: SettingsPage): page is DonationPage { type DonationHeroProps = Pick< PropsDataType, 'badge' | 'color' | 'firstName' | 'i18n' | 'profileAvatarUrl' | 'theme' ->; +> & { + showPrivacyModal: () => void; +}; function DonationHero({ badge, @@ -120,35 +122,25 @@ function DonationHero({ i18n, profileAvatarUrl, theme, + showPrivacyModal, }: DonationHeroProps): JSX.Element { - const [showPrivacyModal, setShowPrivacyModal] = useState(false); - - const ReadMoreButtonWithModal = useCallback( + const privacyReadMoreLink = useCallback( (parts: ReactNode): JSX.Element => { return ( ); }, - [] + [showPrivacyModal] ); return ( <> - {showPrivacyModal && ( - setShowPrivacyModal(false)} - /> - )} -
{ setPage(newPage); @@ -517,6 +511,7 @@ export function PreferencesDonations({ i18n={i18n} profileAvatarUrl={profileAvatarUrl} theme={theme} + showPrivacyModal={() => setIsPrivacyModalVisible(true)} /> ), [badge, color, firstName, i18n, profileAvatarUrl, theme] @@ -607,12 +602,20 @@ export function PreferencesDonations({ } } + const privacyModal = isPrivacyModalVisible ? ( + setIsPrivacyModalVisible(false)} + /> + ) : null; + let content; if (page === SettingsPage.DonationsDonateFlow) { // DonateFlow has to control Back button to switch between CC form and Amount picker return ( <> {dialog} + {privacyModal} setIsPrivacyModalVisible(true)} onBack={() => setPage(SettingsPage.Donations)} /> @@ -675,6 +679,7 @@ export function PreferencesDonations({ return ( <> {dialog} + {privacyModal} { updated = await _redeemReceipt(existing); // continuing } else if (type === donationStateSchema.Enum.DONE) { - if (!isDonationPageVisible()) { + if (isDonationPageVisible()) { + if (isDonationsDonateFlowVisible()) { + window.reduxActions.nav.changeLocation({ + tab: NavTab.Settings, + details: { + page: SettingsPage.Donations, + }, + }); + + // TODO: Replace with DESKTOP-8959 + window.reduxActions.toast.showToast({ + toastType: ToastType.DonationCompleted, + }); + } + } else { log.info( `${logId}: Donation page not visible. Showing complete toast.` ); @@ -919,6 +933,14 @@ function isDonationPageVisible() { ); } +function isDonationsDonateFlowVisible() { + const { selectedLocation } = window.reduxStore.getState().nav; + return ( + selectedLocation.tab === NavTab.Settings && + selectedLocation.details.page === SettingsPage.DonationsDonateFlow + ); +} + // Working with zkgroup receipts function getServerPublicParams(): ServerPublicParams {