From 3a617fb9ef5d64a6e114012868fa01033cf0bb12 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:31:09 -0500 Subject: [PATCH] Style donation amount picker Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> --- _locales/en/messages.json | 12 ++ stylesheets/_variables.scss | 1 + stylesheets/components/DonationForm.scss | 149 +++++++++++++++++ .../components/PreferencesDonations.scss | 34 +++- stylesheets/manifest.scss | 1 + ts/components/Avatar.tsx | 1 + ts/components/Input.tsx | 3 + ts/components/Preferences.stories.tsx | 29 +++- ts/components/PreferencesDonateFlow.tsx | 150 +++++++++++++----- ts/components/PreferencesDonations.tsx | 114 ++++++++----- ts/state/smart/PreferencesDonations.tsx | 18 ++- 11 files changed, 414 insertions(+), 98 deletions(-) create mode 100644 stylesheets/components/DonationForm.scss diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a9086934e9..797fa4c337 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8866,6 +8866,18 @@ "messageformat": "Date paid", "description": "Label for the payment date in donation receipt modal" }, + "icu:DonateFlow__make-a-one-time-donation": { + "messageformat": "Make a one time donation", + "description": "In the Donations settings section after beginning the donations process, then the user selects currency and amount which they want to donate. This string is the section header above currency amount options for a one time donation." + }, + "icu:DonateFlow__desktop-one-time-only-notice": { + "messageformat": "Only one time donations are available on desktop. Monthly donations can be made on your mobile device.", + "description": "In the Donations settings section after beginning the donations process, then the user selects currency and amount which they want to donate. This footer notice indicates that only one time donations are available on desktop, and recurring donations must be processed on the mobile device." + }, + "icu:DonateFlow__having-issues-contact-support": { + "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:DonationReceipt__title": { "messageformat": "Donation receipt", "description": "Title shown at the top of donation receipt documents" diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 995dedc046..0d0bceeb1b 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -40,6 +40,7 @@ $color-gray-65: #4a4a4a; $color-gray-75: #3b3b3b; $color-gray-78: #343434; $color-gray-80: #2e2e2e; +$color-gray-85: #262626; $color-gray-90: #1b1b1b; $color-gray-95: #121212; $color-black: #000000; diff --git a/stylesheets/components/DonationForm.scss b/stylesheets/components/DonationForm.scss new file mode 100644 index 0000000000..bb37602b98 --- /dev/null +++ b/stylesheets/components/DonationForm.scss @@ -0,0 +1,149 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../mixins'; +@use '../variables'; + +.DonationForm { + text-align: center; +} + +.DonationForm__CurrencySelect { + width: 78px; + margin-inline: auto; +} + +.DonationForm__CurrencySelect.module-select select { + height: auto; + min-width: auto; + padding-block: 5px; +} + +.DonationForm .DonationForm__CurrencySelect.module-select select { + border-color: light-dark( + variables.$color-gray-25, + variables.$color-white-alpha-12 + ); +} + +.DonationForm__HelpFooter { + @include mixins.font-body-small; + flex-grow: 1; + color: light-dark( + variables.$color-black-alpha-50, + variables.$color-white-alpha-50 + ); + align-content: flex-end; + text-align: center; +} + +.DonationForm__HelpFooterDesktopOneTimeOnlyNotice { + margin-block: 10px 17px; +} + +a.DonationFormHelpFooter__ContactSupportLink { + color: variables.$color-ultramarine; + text-decoration: none; +} + +.DonationAmountPicker__AmountOptions { + display: flex; + flex-wrap: wrap; + max-width: 340px; + justify-content: center; +} + +.DonationAmountPicker__PresetButton, +.DonationForm.PreferencesDonations + .DonationAmountPicker__CustomInput__container, +.DonationForm .DonationAmountPicker__CustomInput--selected__container, +.DonationForm .DonationAmountPicker__CustomInput--with-error__container { + margin-block: 5px; + margin-inline: 5px; + border-width: 0.5px; + border-style: solid; + border-radius: 6px; + font-weight: 400; +} + +.DonationAmountPicker__PresetButton, +.DonationForm .DonationForm__CurrencySelect.module-select select, +.DonationForm.PreferencesDonations + .DonationAmountPicker__CustomInput__container, +.DonationForm.PreferencesDonations + .DonationAmountPicker__CustomInput--selected__container, +.DonationForm.PreferencesDonations + .DonationAmountPicker__CustomInput--with-error__container { + background-color: light-dark( + variables.$color-white, + variables.$color-gray-85 + ); + border-color: light-dark( + variables.$color-gray-25, + variables.$color-white-alpha-12 + ); +} + +.DonationAmountPicker__PresetButton { + @include mixins.font-body-1; + width: 100px; + padding-inline: 12px; + padding-block: 14px; + margin-block: 5px; + margin-inline: 5px; + font-weight: 400; +} + +.DonationAmountPicker__PresetButton--selected, +.DonationForm .DonationAmountPicker__CustomInput--selected__container, +.DonationForm + .DonationAmountPicker__CustomInput--with-error__container:focus-within, +.DonationForm .DonationAmountPicker__CustomInput__container:focus-within { + border-color: variables.$color-ultramarine; + outline: 2.5px solid variables.$color-ultramarine; + outline-offset: -2.5px; +} + +.DonationForm .DonationAmountPicker__CustomInput__container, +.DonationForm .DonationAmountPicker__CustomInput--selected__container, +.DonationForm .DonationAmountPicker__CustomInput--with-error__container { + width: 320px; + padding-block: 0; + border-width: 0.5px; +} + +.DonationForm + .DonationAmountPicker__CustomInput--with-error__container:not(:focus-within) { + border-color: variables.$color-deep-red; + outline: 2.5px solid variables.$color-deep-red; + outline-offset: -2.5px; +} + +.DonationForm .DonationAmountPicker__CustomInput__input, +.DonationForm .DonationAmountPicker__CustomInput--selected__input, +.DonationForm .DonationAmountPicker__CustomInput--with-error__input { + @include mixins.font-body-1; + padding-inline: 12px; + padding-block: 14px; + text-align: center; +} + +.DonationAmountPicker__CustomInput__input:not(:focus)::placeholder { + color: light-dark( + variables.$color-black-alpha-85, + variables.$color-white-alpha-85 + ); + opacity: 1; +} + +.DonationAmountPicker__CustomInput__input:focus::placeholder, +.DonationAmountPicker__CustomInput--selected__input:focus::placeholder, +.DonationAmountPicker__CustomInput--with-error__input:focus::placeholder { + color: transparent; +} + +.DonationAmountPicker__PrimaryButtonContainer { + margin-block-start: 11px; + margin-inline-end: 10px; + text-align: end; +} diff --git a/stylesheets/components/PreferencesDonations.scss b/stylesheets/components/PreferencesDonations.scss index 4615447a76..f03a76ff75 100644 --- a/stylesheets/components/PreferencesDonations.scss +++ b/stylesheets/components/PreferencesDonations.scss @@ -8,6 +8,7 @@ display: flex; flex-direction: column; align-items: center; + min-height: 590px; padding-block: 0; padding-inline: 0; margin-inline-start: 24px; @@ -15,14 +16,14 @@ &__title { @include mixins.font-title-medium; - margin-bottom: 16px; + margin-bottom: 8px; } &__description { @include mixins.font-body-2; text-align: center; max-width: 320px; - margin-bottom: 24px; + margin-block-end: 12px; color: light-dark( variables.$color-black-alpha-50, variables.$color-white-alpha-50 @@ -42,7 +43,7 @@ } &__donate-button { - margin-bottom: 24px; + margin-block-end: 32px; } &__separator { @@ -60,13 +61,20 @@ &__section-header { @include mixins.font-body-2-bold; width: 100%; - margin-top: 12px; - margin-bottom: 12px; + margin-block: 12px; padding-inline: 8px; color: light-dark( variables.$color-black-alpha-85, variables.$color-white-alpha-85 ); + + &--my-support { + margin-block-start: 6px; + } + + &--donate-flow { + margin-block-start: 22px; + } } &__list { @@ -161,9 +169,9 @@ &__mobile-info { @include mixins.font-subtitle; - margin-top: 18px; - align-self: flex-start; padding-inline: 8px; + margin-block-start: 10px; + align-self: flex-start; color: light-dark( variables.$color-black-alpha-50, variables.$color-white-alpha-50 @@ -171,6 +179,10 @@ } } +.PreferencesDonations__avatar { + margin-block-end: 12px; +} + // Receipts page specific styles .PreferencesDonations--receiptList { &__info { @@ -386,3 +398,11 @@ } } } + +.PreferencesDonations__PrimaryButton { + @include mixins.font-body-2; + padding-block: 5px; + padding-inline: 12px; + font-weight: 400; + border-radius: 6px; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index f2a2f134df..1a37fa252d 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -97,6 +97,7 @@ @use 'components/DisappearingTimeDialog.scss'; @use 'components/DisappearingTimerSelect.scss'; @use 'components/DonationErrorModal.scss'; +@use 'components/DonationForm.scss'; @use 'components/DonationProgressModal.scss'; @use 'components/DonationStillProcessingModal.scss'; @use 'components/DonationVerificationModal.scss'; diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index d04ab0d9da..0ff4d67c87 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -46,6 +46,7 @@ export enum AvatarSize { FORTY = 40, FORTY_EIGHT = 48, FIFTY_TWO = 52, + SEVENTY_TWO = 72, SIXTY_FOUR = 64, EIGHTY = 80, NINETY_SIX = 96, diff --git a/ts/components/Input.tsx b/ts/components/Input.tsx index d82688f8aa..eacef3013a 100644 --- a/ts/components/Input.tsx +++ b/ts/components/Input.tsx @@ -34,6 +34,7 @@ export type PropsType = { moduleClassName?: string; onChange: (value: string) => unknown; onBlur?: () => unknown; + onFocus?: () => unknown; onEnter?: () => unknown; placeholder: string; value?: string; @@ -80,6 +81,7 @@ export const Input = forwardRef< moduleClassName, onChange, onBlur, + onFocus, onEnter, placeholder, value = '', @@ -220,6 +222,7 @@ export const Input = forwardRef< spellCheck: !disableSpellcheck, onChange: handleChange, onBlur, + onFocus, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder, diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 5296d2ac82..8136c59b80 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -195,6 +195,7 @@ function RenderProfileEditor(): JSX.Element { function RenderDonationsPane(props: { me: typeof me; donationReceipts: ReadonlyArray; + page: SettingsPage; saveAttachmentToDisk: (options: { data: Uint8Array; name: string; @@ -212,12 +213,12 @@ function RenderDonationsPane(props: { i18n={i18n} contentsRef={contentsRef} clearWorkflow={action('clearWorkflow')} - isStaging={false} - page={SettingsPage.Donations} + isStaging + page={props.page} setPage={action('setPage')} submitDonation={action('submitDonation')} workflow={undefined} - userAvatarData={[]} + badge={undefined} color={props.me.color} firstName={props.me.firstName} profileAvatarUrl={props.me.profileAvatarUrl} @@ -227,6 +228,7 @@ function RenderDonationsPane(props: { saveAttachmentToDisk={props.saveAttachmentToDisk} generateDonationReceiptBlob={props.generateDonationReceiptBlob} showToast={props.showToast} + theme={ThemeType.light} /> ); } @@ -353,6 +355,7 @@ export default { RenderDonationsPane({ me, donationReceipts: [], + page: SettingsPage.Donations, saveAttachmentToDisk: async () => { action('saveAttachmentToDisk')(); return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; @@ -516,6 +519,26 @@ Donations.args = { donationsFeatureEnabled: true, page: SettingsPage.Donations, }; +export const DonationsDonateFlow = Template.bind({}); +DonationsDonateFlow.args = { + donationsFeatureEnabled: true, + page: SettingsPage.DonationsDonateFlow, + renderDonationsPane: () => + RenderDonationsPane({ + me, + donationReceipts: [], + page: SettingsPage.DonationsDonateFlow, + saveAttachmentToDisk: async () => { + action('saveAttachmentToDisk')(); + return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; + }, + generateDonationReceiptBlob: async () => { + action('generateDonationReceiptBlob')(); + return new Blob(); + }, + showToast: action('showToast'), + }), +}; export const Internal = Template.bind({}); Internal.args = { page: SettingsPage.Internal, diff --git a/ts/components/PreferencesDonateFlow.tsx b/ts/components/PreferencesDonateFlow.tsx index 2c2c862bd2..a8325e7182 100644 --- a/ts/components/PreferencesDonateFlow.tsx +++ b/ts/components/PreferencesDonateFlow.tsx @@ -10,6 +10,7 @@ import React, { useState, } from 'react'; +import classNames from 'classnames'; import type { LocalizerType } from '../types/Util'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { Button, ButtonVariant } from './Button'; @@ -55,12 +56,16 @@ import { DonateInputCardCvc, getCardCvcErrorMessage, } from './preferences/donations/DonateInputCardCvc'; +import { I18n } from './I18n'; + +const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop'; export type PropsDataType = { i18n: LocalizerType; donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; validCurrencies: ReadonlyArray; workflow: DonationWorkflow | undefined; + renderDonationHero: () => JSX.Element; }; type PropsHousekeepingType = { @@ -82,6 +87,7 @@ export function PreferencesDonateFlow({ validCurrencies, workflow, clearWorkflow, + renderDonationHero, submitDonation, onBack, }: PropsType): JSX.Element { @@ -191,8 +197,8 @@ export function PreferencesDonateFlow({ // TODO: DESKTOP-8950 }; - confirmDiscardIf(true, onDiscard); - }, [confirmDiscardIf]); + confirmDiscardIf(step === 'paymentDetails', onDiscard); + }, [confirmDiscardIf, step]); tryClose.current = onTryClose; let innerContent: JSX.Element; @@ -200,14 +206,18 @@ export function PreferencesDonateFlow({ if (step === 'amount') { innerContent = ( - + <> + {renderDonationHero()} + + + ); // Dismiss DonateFlow and return to Donations home handleBack = () => onBack(); @@ -287,10 +297,10 @@ export function PreferencesDonateFlow({ /> ); const content = ( - <> +
{confirmDiscardModal} {innerContent} - +
); return ( @@ -321,16 +331,16 @@ function AmountPicker({ donationAmountsConfig, i18n, initialAmount, - initialCurrency, + initialCurrency = 'usd', validCurrencies, onSubmit, }: AmountPickerProps): JSX.Element { - const [currency, setCurrency] = useState(initialCurrency ?? 'usd'); + const [currency, setCurrency] = useState(initialCurrency); const [presetAmount, setPresetAmount] = useState< HumanDonationAmount | undefined - >(initialAmount); - const [customAmount, setCustomAmount] = useState(); + >(); + const [customAmount, setCustomAmount] = useState(); // Reset amount selections when API donation config or selected currency changes // Memo here so preset options instantly load when component mounts. @@ -345,9 +355,17 @@ function AmountPicker({ }, [donationAmountsConfig, currency]); useEffect(() => { - setCustomAmount(undefined); - setPresetAmount(undefined); - }, [donationAmountsConfig, currency]); + if ( + initialAmount && + presetAmountOptions.find(option => option === initialAmount) + ) { + setPresetAmount(initialAmount); + setCustomAmount(undefined); + } else { + setPresetAmount(undefined); + setCustomAmount(initialAmount?.toString()); + } + }, [initialAmount, presetAmountOptions]); const minimumAmount = useMemo(() => { if (!donationAmountsConfig || !donationAmountsConfig[currency]) { @@ -422,51 +440,103 @@ function AmountPicker({ onSubmit({ amount, currency }); }, [amount, currency, isContinueEnabled, onSubmit]); + let customInputClassName; + if (error) { + customInputClassName = 'DonationAmountPicker__CustomInput--with-error'; + } else if (parsedCustomAmount) { + customInputClassName = 'DonationAmountPicker__CustomInput--selected'; + } else { + customInputClassName = 'DonationAmountPicker__CustomInput'; + } + return ( -
+
setPresetAmount(undefined)} placeholder="Enter Custom Amount" value={customAmount} /> - {currency.toUpperCase()}
- {error &&
Error: {error}
} - +
+ +
+
+ ); +} + +type HelpFooterProps = { + i18n: LocalizerType; + showOneTimeOnlyNotice?: boolean; +}; + +function HelpFooter({ + i18n, + showOneTimeOnlyNotice, +}: HelpFooterProps): JSX.Element { + const contactSupportLink = (parts: Array) => ( + + {parts} + + ); + + return ( +
+ {showOneTimeOnlyNotice && ( +
+ {i18n('icu:DonateFlow__desktop-one-time-only-notice')} +
+ )} +
); } diff --git a/ts/components/PreferencesDonations.tsx b/ts/components/PreferencesDonations.tsx index d52da8daf6..b489c0613f 100644 --- a/ts/components/PreferencesDonations.tsx +++ b/ts/components/PreferencesDonations.tsx @@ -8,7 +8,7 @@ import type { MutableRefObject, ReactNode } from 'react'; import { ListBox, ListBoxItem } from 'react-aria-components'; import { getDateTimeFormatter } from '../util/formatTimestamp'; -import type { LocalizerType } from '../types/Util'; +import type { LocalizerType, ThemeType } from '../types/Util'; import { PreferencesContent } from './Preferences'; import { SettingsPage } from '../types/Nav'; import { PreferencesDonateFlow } from './PreferencesDonateFlow'; @@ -18,8 +18,6 @@ import type { OneTimeDonationHumanAmounts, } from '../types/Donations'; import type { AvatarColorType } from '../types/Colors'; -import type { AvatarDataType } from '../types/Avatar'; -import { AvatarPreview } from './AvatarPreview'; import { Button, ButtonSize, ButtonVariant } from './Button'; import { Modal } from './Modal'; import { Spinner } from './Spinner'; @@ -32,6 +30,8 @@ import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser'; import { DonationPrivacyInformationModal } from './DonationPrivacyInformationModal'; import type { SubmitDonationType } from '../state/ducks/donations'; import { getHumanDonationAmount } from '../util/currency'; +import { Avatar, AvatarSize } from './Avatar'; +import type { BadgeType } from '../badges/types'; const log = createLogger('PreferencesDonations'); @@ -44,13 +44,14 @@ export type PropsDataType = { isStaging: boolean; page: SettingsPage; workflow: DonationWorkflow | undefined; - userAvatarData: ReadonlyArray; - color?: AvatarColorType; - firstName?: string; + badge: BadgeType | undefined; + color: AvatarColorType | undefined; + firstName: string | undefined; profileAvatarUrl?: string; donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; validCurrencies: ReadonlyArray; donationReceipts: ReadonlyArray; + theme: ThemeType; saveAttachmentToDisk: (options: { data: Uint8Array; name: string; @@ -76,8 +77,9 @@ type DonationPage = | SettingsPage.DonationsDonateFlow | SettingsPage.DonationsReceiptList; -type PreferencesHomeProps = PropsType & { +type PreferencesHomeProps = Omit & { navigateToPage: (newPage: SettingsPage) => void; + renderDonationHero: () => JSX.Element; }; function isDonationPage(page: SettingsPage): page is DonationPage { @@ -88,20 +90,19 @@ function isDonationPage(page: SettingsPage): page is DonationPage { ); } -function DonationsHome({ - i18n, - userAvatarData, +type DonationHeroProps = Pick< + PropsDataType, + 'badge' | 'color' | 'firstName' | 'i18n' | 'profileAvatarUrl' | 'theme' +>; + +function DonationHero({ + badge, color, firstName, + i18n, profileAvatarUrl, - navigateToPage, - setPage, - isStaging, - donationReceipts, -}: PreferencesHomeProps): JSX.Element { - const avatarData = userAvatarData[0]; - const avatarBuffer = avatarData?.buffer; - const hasReceipts = donationReceipts.length > 0; + theme, +}: DonationHeroProps): JSX.Element { const [showPrivacyModal, setShowPrivacyModal] = useState(false); const ReadMoreButtonWithModal = useCallback( @@ -122,18 +123,25 @@ function DonationsHome({ ); return ( -
-
- + {showPrivacyModal && ( + setShowPrivacyModal(false)} + /> + )} + +
+
@@ -148,9 +156,26 @@ function DonationsHome({ id="icu:PreferencesDonations__description" />
+ + ); +} + +function DonationsHome({ + i18n, + renderDonationHero, + navigateToPage, + setPage, + isStaging, + donationReceipts, +}: PreferencesHomeProps): JSX.Element { + const hasReceipts = donationReceipts.length > 0; + + return ( +
+ {renderDonationHero()} {isStaging && (