Style donations card form

This commit is contained in:
ayumi-signal
2025-07-31 14:48:12 -07:00
committed by GitHub
parent a7cd27f3cf
commit a4ef26877d
11 changed files with 566 additions and 190 deletions

View File

@@ -8886,6 +8886,50 @@
"messageformat": "Having issues? <contactSupportLink>Contact support</contactSupportLink>",
"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. <learnMoreLink>Learn more</learnMoreLink>",
"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"

41
images/rocket-160.svg Normal file
View File

@@ -0,0 +1,41 @@
<svg id="Export" xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
<defs>
<style>
.cls-1 {
fill: #e3e3fe;
}
.cls-2 {
fill: #c5c5ef;
}
.cls-3 {
fill: #cde4cd;
}
.cls-4 {
fill: #aca;
}
.cls-5 {
fill: #fdf1c0;
}
.cls-6 {
fill: #401968;
}
</style>
</defs>
<circle class="cls-1" cx="80" cy="80" r="80"/>
<path class="cls-2" d="M158.63,94.68A80,80,0,1,0,.8,91.2C60.49,79.26,120.78,82.11,158.63,94.68Z"/>
<path class="cls-3" d="M111.5,19.83l-.31.47.14-.3s-27,3.67-39.66,20.33-20.34,52.4-20.34,52.4A25.29,25.29,0,0,0,64,104a.88.88,0,0,0,.56,0,.1.1,0,0,0,0,0A25.24,25.24,0,0,0,80.83,109s26.46-25.2,34.05-44.71S111.5,19.83,111.5,19.83Z"/>
<path class="cls-4" d="M110.8,22.07l-.41.05c-1.67,8.09-5.51,25-11.06,39.21-5.84,15-21.61,35.31-28.59,43.89a27.94,27.94,0,0,0,9.26,2s25.23-23.77,33.26-42.93C120.66,46.66,110.8,22.07,110.8,22.07Z"/>
<circle class="cls-5" cx="87" cy="61.67" r="12.67"/>
<path class="cls-6" d="M92,47.88a14.66,14.66,0,1,0,9.67,13.79A14.9,14.9,0,0,0,92,47.88ZM90.78,71.65a10.67,10.67,0,1,1,6.89-10A10.79,10.79,0,0,1,90.78,71.65ZM119,40.06a79.77,79.77,0,0,0-5.43-20.51,1.78,1.78,0,0,0-.29-.71v0a2,2,0,0,0-2.74-.72l0,0a75.7,75.7,0,0,0-15.65,4.2C85,26,75.69,31.45,69.44,40c-6.1,8.35-9.95,18.4-13.26,28.12-.57,1.68-1.11,3.36-1.65,5l-5.4,1.45a1,1,0,0,0-.62.49L33.73,103.86a1,1,0,0,0,1.36,1.28l15.8-9.28a26.61,26.61,0,0,0,4.42,5,30.25,30.25,0,0,0,3.47,2.61l-7.12,15.13A1.91,1.91,0,0,0,55,120.43L65,106.8c.75.44,1.56.83,2.24,1.17a29.31,29.31,0,0,0,4.63,1.87A27.54,27.54,0,0,0,77.67,111v18.54a1,1,0,0,0,1.79.52L96.77,102.7a.94.94,0,0,0,.11-.78l-1.49-5.39,1.24-1.41C103.08,87.65,109.32,79.74,114,71a45.08,45.08,0,0,0,5-14.27A58.92,58.92,0,0,0,119,40.06ZM114.43,59c-1.89,7.86-6.5,14.93-11.17,21.41a192.84,192.84,0,0,1-15,18c-2.7,2.93-5.45,5.81-8.32,8.57a22.36,22.36,0,0,1-11.12-2.71c-.66-.35-1.3-.72-1.92-1.12l8.59-17.13a1.91,1.91,0,0,0-3.29-1.93l-11.33,16L60,99.5a24.7,24.7,0,0,1-5.11-4.86,18.9,18.9,0,0,1-1.47-2.18A201.62,201.62,0,0,1,63.92,58.8c3.12-7.57,6.9-15.43,12.85-21.2S90.25,28.08,98,25.43a77.83,77.83,0,0,1,12.34-3.26c.23.61.47,1.21.68,1.83a87,87,0,0,1,2.66,9.1C115.61,41.51,116.46,50.53,114.43,59Z"/>
<path class="cls-6" d="M60.05,146.38q-1.81,3.3-3.74,6.54a2,2,0,0,1-3.46-2q1.93-3.24,3.74-6.54a2,2,0,1,1,3.46,2Z"/>
<path class="cls-6" d="M63.16,130.83l-.08.19c0-.06.09-.21.11-.26l.22-.54a2.45,2.45,0,0,1,.92-1.2,2,2,0,0,1,2.74.72,2,2,0,0,1,.2,1.54c-1.13,2.72-2.32,5.41-3.61,8.07a2,2,0,0,1-2.73.72,2.05,2.05,0,0,1-.72-2.74c.59-1.23,1.17-2.46,1.73-3.7l.81-1.84C62.88,131.47,63,131.16,63.16,130.83Z"/>
<path class="cls-6" d="M72.69,115.62a2,2,0,0,1,.2,1.54c-1,2.41-1.9,4.82-2.85,7.23a2.29,2.29,0,0,1-.92,1.2,2,2,0,0,1-2.73-.72,2,2,0,0,1-.2-1.54L69,116.1a2.34,2.34,0,0,1,.92-1.2,2.06,2.06,0,0,1,1.54-.2A2.09,2.09,0,0,1,72.69,115.62Z"/>
<path class="cls-6" d="M24.89,137.71a2,2,0,0,1-.72-2.73c1.26-2.18,2.55-4.33,3.89-6.46a2,2,0,0,1,2.73-.71,2,2,0,0,1,.72,2.73c-1.33,2.13-2.63,4.28-3.88,6.46A2,2,0,0,1,24.89,137.71Z"/>
<path class="cls-6" d="M39.82,118.59c-.07,0-.26.35-.31.41l-1.23,1.63c-.82,1.1-1.61,2.21-2.4,3.32a2,2,0,0,1-2.73.72,2,2,0,0,1-.72-2.73c1.69-2.42,3.45-4.77,5.28-7.08a1.91,1.91,0,0,1,1.41-.59,2,2,0,0,1,2,2,2.12,2.12,0,0,1-.59,1.41l-.63.81-.15.2C39.61,118.86,39.64,118.83,39.82,118.59Z"/>
<path class="cls-6" d="M43.78,112.49a2,2,0,0,1-2-2,2.12,2.12,0,0,1,.59-1.41l4.92-6a1.91,1.91,0,0,1,1.41-.59,2,2,0,0,1,1.42.59,2,2,0,0,1,.58,1.41,2.2,2.2,0,0,1-.58,1.42l-4.92,6A1.93,1.93,0,0,1,43.78,112.49Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -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%;
}

View File

@@ -56,6 +56,10 @@
variables.$color-black-alpha-12,
variables.$color-white-alpha-12
);
&--card-form {
margin-block: 20px 12px;
}
}
&__section-header {

View File

@@ -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}

View File

@@ -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<HumanDonationAmount>();
const [currency, setCurrency] = useState<string>(initialCurrency);
const [cardExpiration, setCardExpiration] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [cardCvc, setCardCvc] = useState('');
const [isDonateDisabled, setIsDonateDisabled] = useState(false);
const [cardNumberError, setCardNumberError] =
useState<CardNumberError | null>(null);
const [cardExpirationError, setCardExpirationError] =
useState<CardExpirationError | null>(null);
const [cardCvcError, setCardCvcError] = useState<CardCvcError | null>(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<string>(() => {
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 = (
<div className="PreferencesDonations">
{workflow && (
<div>
<h2>Current Workflow</h2>
<blockquote>{JSON.stringify(workflow)}</blockquote>
<Button onClick={clearWorkflow} variant={ButtonVariant.Destructive}>
Reset
</Button>
</div>
)}
<label htmlFor="amount">Amount</label>
<pre>
{amount} {currency}
</pre>
<label htmlFor="cardNumber">Card Number</label>
<DonateInputCardNumber
id="cardNumber"
value={cardNumber}
onValueChange={handleCardNumberChange}
maxInputLength={cardFormSettings.cardNumber.maxInputLength}
onBlur={handleCardNumberBlur}
<>
<CardFormHero i18n={i18n} amount={amount} currency={currency} />
<hr className="PreferencesDonations__separator PreferencesDonations__separator--card-form" />
<CardForm
amount={amount}
currency={currency}
disabled={isCardFormDisabled}
i18n={i18n}
initialValues={cardFormValues}
onChange={handleCardFormChanged}
onSubmit={handleSubmitDonation}
showPrivacyModal={showPrivacyModal}
/>
{cardNumberError != null && (
<span>{getCardNumberErrorMessage(i18n, cardNumberError)}</span>
)}
<label htmlFor="cardExpiration">Expiration Date</label>
<DonateInputCardExp
id="cardExpiration"
value={cardExpiration}
onValueChange={handleCardExpirationChange}
onBlur={handleCardExpirationBlur}
/>
{cardExpirationError && (
<span>
{getCardExpirationErrorMessage(i18n, cardExpirationError)}
</span>
)}
<label htmlFor="cardCvc">{cardFormSettings.cardCvc.label}</label>
<DonateInputCardCvc
id="cardCvc"
value={cardCvc}
onValueChange={handleCardCvcChange}
maxInputLength={cardFormSettings.cardCvc.maxInputLength}
onBlur={handleCardCvcBlur}
/>
{cardCvcError && (
<span>{getCardCvcErrorMessage(i18n, cardCvcError)}</span>
)}
<Button
disabled={isDonateDisabled}
onClick={handleDonateClicked}
variant={ButtonVariant.Primary}
>
{i18n('icu:PreferencesDonations__donate-button-with-amount', {
formattedCurrencyAmount,
})}
</Button>
</div>
<HelpFooter i18n={i18n} />
</>
);
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<CardNumberError | null>(null);
const [cardExpirationError, setCardExpirationError] =
useState<CardExpirationError | null>(null);
const [cardCvcError, setCardCvcError] = useState<CardCvcError | null>(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 (
<button
type="button"
className="PreferencesDonations__description__read-more"
onClick={showPrivacyModal}
>
{parts}
</button>
);
},
[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<string>(() => {
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 (
<div className="DonationCardForm">
<div className="DonationCardForm__Header--Info PreferencesDonations__section-header">
{i18n('icu:DonateFlow__credit-or-debit-card')}
</div>
<div className="DonationCardForm__Info">
<I18n
components={{
learnMoreLink: privacyLearnMoreLink,
}}
i18n={i18n}
id="icu:DonateFlow__card-form-instructions"
/>
</div>
<div className="DonationCardForm_Field DonationCardForm_CardNumberField">
<label className="DonationCardForm_Label" htmlFor="cardNumber">
{i18n('icu:DonateFlow__card-form-card-number')}
</label>
<div
className={classNames({
'DonationCardForm_InputContainer--with-error':
cardNumberError != null,
})}
>
<DonateInputCardNumber
id="cardNumber"
value={cardNumber}
onValueChange={handleCardNumberChange}
maxInputLength={cardFormSettings.cardNumber.maxInputLength}
onBlur={handleCardNumberBlur}
/>
{cardNumberError != null && (
<div className="DonationCardForm_FieldError">
{getCardNumberErrorMessage(i18n, cardNumberError)}
</div>
)}
</div>
</div>
<div className="DonationCardForm_Field DonationCardForm_CardExpirationField">
<label className="DonationCardForm_Label" htmlFor="cardExpiration">
{i18n('icu:DonateFlow__card-form-expiration-date')}
</label>
<div
className={classNames({
'DonationCardForm_InputContainer--with-error':
cardExpirationError != null,
})}
>
<DonateInputCardExp
id="cardExpiration"
value={cardExpiration}
onValueChange={handleCardExpirationChange}
onBlur={handleCardExpirationBlur}
/>
{cardExpirationError && (
<div className="DonationCardForm_FieldError">
{getCardExpirationErrorMessage(i18n, cardExpirationError)}
</div>
)}
</div>
</div>
<div className="DonationCardForm_Field DonationCardForm_CardCvcField">
<label className="DonationCardForm_Label" htmlFor="cardCvc">
{cardFormSettings.cardCvc.label}
</label>
<div
className={classNames({
'DonationCardForm_InputContainer--with-error': cardCvcError != null,
})}
>
<DonateInputCardCvc
id="cardCvc"
value={cardCvc}
onValueChange={handleCardCvcChange}
maxInputLength={cardFormSettings.cardCvc.maxInputLength}
onBlur={handleCardCvcBlur}
/>
{cardCvcError && (
<div className="DonationCardForm_FieldError">
{getCardCvcErrorMessage(i18n, cardCvcError)}
</div>
)}
</div>
</div>
<div className="DonationCardForm__PrimaryButtonContainer">
<Button
className="PreferencesDonations__PrimaryButton"
disabled={isDonateDisabled}
onClick={handleDonateClicked}
variant={ButtonVariant.Primary}
>
{i18n('icu:PreferencesDonations__donate-button-with-amount', {
formattedCurrencyAmount,
})}
</Button>
</div>
</div>
);
}
type CardFormHeroProps = {
amount: HumanDonationAmount;
currency: string;
i18n: LocalizerType;
};
// Similar to <DonationHero> or renderDonationHero
function CardFormHero({
amount,
currency,
i18n,
}: CardFormHeroProps): JSX.Element {
const formattedCurrencyAmount = useMemo<string>(() => {
return toHumanCurrencyString({ amount, currency });
}, [amount, currency]);
return (
<>
<div className="PreferencesDonations__avatar">
<div className="DonationCardFormHero__Badge" />
</div>
<div className="PreferencesDonations__title">
{i18n('icu:DonateFlow__card-form-title-donate-with-amount', {
formattedCurrencyAmount,
})}
</div>
<div className="PreferencesDonations__description">
{i18n('icu:DonateFlow__one-time-donation-boost-badge-info')}
</div>
</>
);
}
type HelpFooterProps = {
i18n: LocalizerType;
showOneTimeOnlyNotice?: boolean;

View File

@@ -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 (
<button
type="button"
className="PreferencesDonations__description__read-more"
onClick={() => {
setShowPrivacyModal(true);
}}
onClick={showPrivacyModal}
>
{parts}
</button>
);
},
[]
[showPrivacyModal]
);
return (
<>
{showPrivacyModal && (
<DonationPrivacyInformationModal
i18n={i18n}
onClose={() => setShowPrivacyModal(false)}
/>
)}
<div className="PreferencesDonations__avatar">
<Avatar
avatarUrl={profileAvatarUrl}
@@ -168,7 +160,7 @@ function DonationHero({
<div className="PreferencesDonations__description">
<I18n
components={{
readMoreLink: ReadMoreButtonWithModal,
readMoreLink: privacyReadMoreLink,
}}
i18n={i18n}
id="icu:PreferencesDonations__description"
@@ -487,6 +479,8 @@ export function PreferencesDonations({
}: PropsType): JSX.Element | null {
const [hasProcessingExpired, setHasProcessingExpired] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [isPrivacyModalVisible, setIsPrivacyModalVisible] = useState(false);
const navigateToPage = useCallback(
(newPage: SettingsPage) => {
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 ? (
<DonationPrivacyInformationModal
i18n={i18n}
onClose={() => 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}
<PreferencesDonateFlow
contentsRef={contentsRef}
i18n={i18n}
@@ -627,6 +630,7 @@ export function PreferencesDonations({
setIsSubmitted(true);
submitDonation(details);
}}
showPrivacyModal={() => setIsPrivacyModalVisible(true)}
onBack={() => setPage(SettingsPage.Donations)}
/>
</>
@@ -675,6 +679,7 @@ export function PreferencesDonations({
return (
<>
{dialog}
{privacyModal}
<PreferencesContent
backButton={backButton}
contents={content}

View File

@@ -8,18 +8,17 @@ import { CardCvcError } from '../../../types/DonationsCardForm';
import { missingCaseError } from '../../../util/missingCaseError';
export function getCardCvcErrorMessage(
_i18n: LocalizerType,
i18n: LocalizerType,
error: CardCvcError
): string {
switch (error) {
case CardCvcError.EMPTY:
return 'EMPTY';
case CardCvcError.LENGTH_TOO_SHORT:
return 'LENGTH_TOO_SHORT';
return i18n('icu:DonateFlow__card-form-error-cvc-too-short');
case CardCvcError.EMPTY:
case CardCvcError.INVALID_CHARS:
case CardCvcError.LENGTH_TOO_LONG:
case CardCvcError.LENGTH_INVALID:
return 'INVALID';
return i18n('icu:DonateFlow__card-form-error-invalid');
default:
throw missingCaseError(error);
}

View File

@@ -8,18 +8,17 @@ import { missingCaseError } from '../../../util/missingCaseError';
import type { LocalizerType } from '../../../types/I18N';
export function getCardExpirationErrorMessage(
_i18n: LocalizerType,
i18n: LocalizerType,
error: CardExpirationError
): string {
switch (error) {
case CardExpirationError.EMPTY:
return 'EMPTY';
case CardExpirationError.EXPIRED_PAST_YEAR:
case CardExpirationError.EXPIRED_EARLIER_IN_YEAR:
return 'EXPIRED';
return i18n('icu:DonateFlow__card-form-error-expiration-expired');
case CardExpirationError.YEAR_MISSING:
case CardExpirationError.YEAR_EMPTY:
return 'MISSING YEAR';
return i18n('icu:DonateFlow__card-form-error-year-missing');
case CardExpirationError.EMPTY:
case CardExpirationError.INVALID_CHARS:
case CardExpirationError.TOO_MANY_SLASHES:
case CardExpirationError.MONTH_EMPTY:
@@ -30,7 +29,7 @@ export function getCardExpirationErrorMessage(
case CardExpirationError.YEAR_INVALID_INTEGER:
case CardExpirationError.MONTH_OUT_OF_RANGE:
case CardExpirationError.YEAR_TOO_FAR_IN_FUTURE:
return 'INVALID';
return i18n('icu:DonateFlow__card-form-error-invalid');
default:
throw missingCaseError(error);
}

View File

@@ -8,16 +8,15 @@ import { CardNumberError } from '../../../types/DonationsCardForm';
import { missingCaseError } from '../../../util/missingCaseError';
export function getCardNumberErrorMessage(
_i18n: LocalizerType,
i18n: LocalizerType,
error: CardNumberError
): string {
switch (error) {
case CardNumberError.EMPTY:
return 'EMPTY';
case CardNumberError.INVALID_CHARS:
case CardNumberError.INVALID_OR_INCOMPLETE_NUMBER:
case CardNumberError.INVALID_NUMBER:
return 'INVALID';
return i18n('icu:DonateFlow__card-form-error-invalid-card-number');
default:
throw missingCaseError(error);
}

View File

@@ -368,7 +368,21 @@ export async function _runDonationWorkflow(): Promise<void> {
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 {