mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
Style donations card form
This commit is contained in:
@@ -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
41
images/rocket-160.svg
Normal 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 |
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
variables.$color-black-alpha-12,
|
||||
variables.$color-white-alpha-12
|
||||
);
|
||||
|
||||
&--card-form {
|
||||
margin-block: 20px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__section-header {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user