diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 3c1cf89d03..a801cb5189 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -137,6 +137,8 @@ window.SignalContext = { return result; }, + getVersion: () => '7.61.0', + // For test-runner _skipAnimation: () => { Globals.assign({ diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 032b7cb12f..ba418a06d0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8786,6 +8786,37 @@ "messageformat": "What's New", "description": "Title for the whats new modal" }, + "icu:DonationReceipt__title": { + "messageformat": "Donation receipt", + "description": "Title shown at the top of donation receipt documents" + }, + + "icu:DonationReceipt__amount-label": { + "messageformat": "Amount", + "description": "Label for the donation amount field on receipt" + }, + + "icu:DonationReceipt__type-label": { + "messageformat": "Type", + "description": "Label for the donation type field on receipt" + }, + + "icu:DonationReceipt__date-paid-label": { + "messageformat": "Date paid", + "description": "Label for the payment date field on receipt" + }, + "icu:DonationReceipt__type-value--one-time": { + "messageformat": "One-time", + "description": "Value shown for one-time donations on receipt" + }, + "icu:DonationReceipt__payment-method-label": { + "messageformat": "Payment method", + "description": "Label for the payment method field on receipt" + }, + "icu:DonationReceipt__footer-text": { + "messageformat": "Thank you for supporting Signal. Your contribution helps fuel the mission of protecting free expression and enabling secure global communication for millions around the world, through open source privacy technology. If you’re a resident of the United States, please retain this receipt for your tax records. Signal Technology Foundation is a tax-exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82-4506840.", + "description": "Footer text shown on donation receipts explaining tax deductibility and Signal's mission" + }, "icu:WhatsNew__bugfixes": { "messageformat": "This version contains a number of small tweaks and bug fixes to keep Signal running smoothly.", "description": "Release notes for releases that only include bug fixes", diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 5680ceb653..c89b76a141 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -380,6 +380,16 @@ export default { result: validateBackupResult, }; }, + donationReceipts: [], + internalAddDonationReceipt: action('internalAddDonationReceipt'), + saveAttachmentToDisk: async () => { + action('saveAttachmentToDisk')(); + return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; + }, + generateDonationReceiptBlob: async () => { + action('generateDonationReceiptBlob')(); + return new Blob(); + }, } satisfies PropsType, } satisfies Meta; diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 9cf8f54235..d8a33177e8 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -87,6 +87,7 @@ import type { PromptOSAuthReasonType, PromptOSAuthResultType, } from '../util/os/promptOSAuthMain'; +import type { DonationReceipt } from '../types/Donations'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import { EditChatFoldersPage } from './preferences/EditChatFoldersPage'; import { ChatFoldersPage } from './preferences/ChatFoldersPage'; @@ -194,6 +195,8 @@ export type PropsDataType = { availableCameras: Array< Pick >; + + donationReceipts: ReadonlyArray; } & Omit; type PropsFunctionType = { @@ -246,6 +249,17 @@ type PropsFunctionType = { showToast: (toast: AnyToast) => unknown; validateBackup: () => Promise; + internalAddDonationReceipt: (receipt: DonationReceipt) => void; + saveAttachmentToDisk: (options: { + data: Uint8Array; + name: string; + baseDir?: string | undefined; + }) => Promise<{ fullPath: string; name: string } | null>; + generateDonationReceiptBlob: ( + receipt: DonationReceipt, + i18n: LocalizerType + ) => Promise; + // Change handlers onAudioNotificationsChange: CheckboxChangeHandlerType; onAutoConvertEmojiChange: CheckboxChangeHandlerType; @@ -493,6 +507,10 @@ export function Preferences({ whoCanFindMe, whoCanSeeMe, zoomFactor, + donationReceipts, + internalAddDonationReceipt, + saveAttachmentToDisk, + generateDonationReceiptBlob, }: PropsType): JSX.Element { const storiesId = useId(); const themeSelectId = useId(); @@ -2157,6 +2175,10 @@ export function Preferences({ validateBackup={validateBackup} getMessageCountBySchemaVersion={getMessageCountBySchemaVersion} getMessageSampleForSchemaVersion={getMessageSampleForSchemaVersion} + donationReceipts={donationReceipts} + internalAddDonationReceipt={internalAddDonationReceipt} + saveAttachmentToDisk={saveAttachmentToDisk} + generateDonationReceiptBlob={generateDonationReceiptBlob} /> } contentsRef={settingsPaneRef} diff --git a/ts/components/PreferencesInternal.tsx b/ts/components/PreferencesInternal.tsx index b4f1b5a9b7..c180c60f1a 100644 --- a/ts/components/PreferencesInternal.tsx +++ b/ts/components/PreferencesInternal.tsx @@ -3,6 +3,7 @@ import React, { useState, useCallback } from 'react'; import classNames from 'classnames'; +import { v4 as uuid } from 'uuid'; import type { LocalizerType } from '../types/I18N'; import { toLogFormat } from '../types/errors'; @@ -14,6 +15,11 @@ import { Button, ButtonVariant } from './Button'; import { Spinner } from './Spinner'; import type { MessageCountBySchemaVersionType } from '../sql/Interface'; import type { MessageAttributesType } from '../model-types'; +import type { DonationReceipt } from '../types/Donations'; +import { createLogger } from '../logging/log'; +import { isStagingServer } from '../util/isStagingServer'; + +const log = createLogger('PreferencesInternal'); export function PreferencesInternal({ i18n, @@ -21,6 +27,10 @@ export function PreferencesInternal({ validateBackup: doValidateBackup, getMessageCountBySchemaVersion, getMessageSampleForSchemaVersion, + donationReceipts, + internalAddDonationReceipt, + saveAttachmentToDisk, + generateDonationReceiptBlob, }: { i18n: LocalizerType; exportLocalBackup: () => Promise; @@ -29,6 +39,17 @@ export function PreferencesInternal({ getMessageSampleForSchemaVersion: ( version: number ) => Promise>; + donationReceipts: ReadonlyArray; + internalAddDonationReceipt: (receipt: DonationReceipt) => void; + saveAttachmentToDisk: (options: { + data: Uint8Array; + name: string; + baseDir?: string | undefined; + }) => Promise<{ fullPath: string; name: string } | null>; + generateDonationReceiptBlob: ( + receipt: DonationReceipt, + i18n: LocalizerType + ) => Promise; }): JSX.Element { const [isExportPending, setIsExportPending] = useState(false); const [exportResult, setExportResult] = useState< @@ -120,6 +141,52 @@ export function PreferencesInternal({ } }, [doExportLocalBackup]); + // Donation receipt states + const [isGeneratingReceipt, setIsGeneratingReceipt] = useState(false); + + const handleAddTestReceipt = useCallback(async () => { + const testReceipt: DonationReceipt = { + id: uuid(), + currencyType: 'USD', + paymentAmount: Math.floor(Math.random() * 10000) + 100, // Random amount between $1 and $100 (in cents) + timestamp: Date.now(), + paymentType: 'CARD', + paymentDetail: { + lastFourDigits: Math.floor(1000 + Math.random() * 9000).toString(), + }, + }; + + try { + await internalAddDonationReceipt(testReceipt); + } catch (error) { + log.error('Error adding test receipt:', toLogFormat(error)); + } + }, [internalAddDonationReceipt]); + + const handleGenerateReceipt = useCallback( + async (receipt: DonationReceipt) => { + setIsGeneratingReceipt(true); + try { + const blob = await generateDonationReceiptBlob(receipt, i18n); + const buffer = await blob.arrayBuffer(); + + const result = await saveAttachmentToDisk({ + name: `Signal_Receipt_${new Date(receipt.timestamp).toISOString().split('T')[0]}.png`, + data: new Uint8Array(buffer), + }); + + if (result) { + log.info('Receipt saved to:', result.fullPath); + } + } catch (error) { + log.error('Error generating receipt:', toLogFormat(error)); + } finally { + setIsGeneratingReceipt(false); + } + }, + [i18n, saveAttachmentToDisk, generateDonationReceiptBlob] + ); + return (
) : null} + + {isStagingServer() && ( + + +
+ Test donation receipt generation functionality +
+
+ +
+
+ + {donationReceipts.length > 0 ? ( +
+

Receipts ({donationReceipts.length})

+ + + + + + + + + + + + {donationReceipts.map(receipt => ( + + + + + + + + ))} + +
Date + Amount + + Last 4 + ID + Actions +
+ {new Date(receipt.timestamp).toLocaleDateString()} + + ${(receipt.paymentAmount / 100).toFixed(2)}{' '} + {receipt.currencyType} + + {receipt.paymentDetail?.lastFourDigits || 'N/A'} + + {receipt.id.substring(0, 8)}... + + +
+
+ ) : ( +
+

+ No receipts found. Add some test receipts above. +

+
+ )} +
+ )}
); } diff --git a/ts/services/allLoaders.ts b/ts/services/allLoaders.ts index 1f0786dddf..3e41ab94f4 100644 --- a/ts/services/allLoaders.ts +++ b/ts/services/allLoaders.ts @@ -13,6 +13,10 @@ import { getDistributionListsForRedux, loadDistributionLists, } from './distributionListLoader'; +import { + getDonationReceiptsForRedux, + loadDonationReceipts, +} from './donationReceiptsLoader'; import { getStoriesForRedux, loadStories } from './storyLoader'; import { getUserDataForRedux, loadUserData } from './userLoader'; import { @@ -40,6 +44,7 @@ export async function loadAll(): Promise { loadCallHistory(), loadCallLinks(), loadDistributionLists(), + loadDonationReceipts(), loadGifsState(), loadNotificationProfiles(), loadRecentEmojis(), @@ -62,6 +67,7 @@ export function getParametersForRedux(): ReduxInitData { callHistory: getCallsHistoryForRedux(), callHistoryUnreadCount: getCallsHistoryUnreadCountForRedux(), callLinks: getCallLinksForRedux(), + donations: getDonationReceiptsForRedux(), gifs: getGifsStateForRedux(), mainWindowStats, menuOptions, diff --git a/ts/services/donationReceiptsLoader.ts b/ts/services/donationReceiptsLoader.ts new file mode 100644 index 0000000000..546f36893c --- /dev/null +++ b/ts/services/donationReceiptsLoader.ts @@ -0,0 +1,24 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { DataReader } from '../sql/Client'; +import { strictAssert } from '../util/assert'; + +import type { DonationReceipt } from '../types/Donations'; +import type { DonationsStateType } from '../state/ducks/donations'; + +let donationReceipts: Array | undefined; + +export async function loadDonationReceipts(): Promise { + donationReceipts = await DataReader.getAllDonationReceipts(); +} + +export function getDonationReceiptsForRedux(): DonationsStateType { + strictAssert( + donationReceipts != null, + 'donation receipts have not been loaded' + ); + return { + receipts: donationReceipts, + }; +} diff --git a/ts/state/ducks/donations.ts b/ts/state/ducks/donations.ts index 4c3ad6d848..da13f78c52 100644 --- a/ts/state/ducks/donations.ts +++ b/ts/state/ducks/donations.ts @@ -2,11 +2,19 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ReadonlyDeep } from 'type-fest'; +import type { ThunkAction } from 'redux-thunk'; import { useBoundActions } from '../../hooks/useBoundActions'; +import { createLogger } from '../../logging/log'; +import * as Errors from '../../types/errors'; +import { isStagingServer } from '../../util/isStagingServer'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { DonationReceipt } from '../../types/Donations'; +import type { StateType as RootStateType } from '../reducer'; +import { DataWriter } from '../../sql/Client'; + +const log = createLogger('donations'); // State @@ -27,15 +35,31 @@ export type DonationsActionType = ReadonlyDeep; // Action Creators -export function addReceipt(receipt: DonationReceipt): AddReceiptAction { - return { - type: ADD_RECEIPT, - payload: { receipt }, +export function internalAddDonationReceipt( + receipt: DonationReceipt +): ThunkAction { + return async dispatch => { + if (!isStagingServer()) { + log.error('internalAddDonationReceipt: Only available on staging server'); + throw new Error('This feature is only available on staging server'); + } + + try { + await DataWriter.createDonationReceipt(receipt); + + dispatch({ + type: ADD_RECEIPT, + payload: { receipt }, + }); + } catch (error) { + log.error('Error adding donation receipt', Errors.toLogFormat(error)); + throw error; + } }; } export const actions = { - addReceipt, + internalAddDonationReceipt, }; export const useDonationsActions = (): BoundActionCreatorsMapObject< diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 82f1ebff08..93d9962f59 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -58,6 +58,7 @@ export function getInitialState( callLinks, callHistory: calls, callHistoryUnreadCount, + donations, gifs, mainWindowStats, menuOptions, @@ -86,6 +87,7 @@ export function getInitialState( ...callingEmptyState(), callLinks: makeLookup(callLinks, 'roomId'), }, + donations, emojis: recentEmoji, gifs, items, diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index d0de66bc78..2a11ace484 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -8,6 +8,7 @@ import { getInitialState } from './getInitialState'; import type { BadgesStateType } from './ducks/badges'; import type { CallHistoryDetails } from '../types/CallDisposition'; +import type { DonationsStateType } from './ducks/donations'; import type { MainWindowStatsType } from '../windows/context'; import type { MenuOptionsType } from '../types/menu'; import type { StoryDataType } from './ducks/stories'; @@ -24,6 +25,7 @@ export type ReduxInitData = { callHistory: ReadonlyArray; callHistoryUnreadCount: number; callLinks: ReadonlyArray; + donations: DonationsStateType; gifs: GifsStateType; mainWindowStats: MainWindowStatsType; menuOptions: MenuOptionsType; diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index 467f581a7c..47b6be6fbd 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -70,12 +70,15 @@ import { DataReader } from '../../sql/Client'; import { deleteAllMyStories } from '../../util/deleteAllMyStories'; import { isLocalBackupsEnabledForRedux } from '../../util/isLocalBackupsEnabled'; import { SmartPreferencesDonations } from './PreferencesDonations'; +import { useDonationsActions } from '../ducks/donations'; +import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt'; import type { StorageAccessType, ZoomFactorType } from '../../types/Storage'; import type { ThemeType } from '../../util/preload'; import type { WidthBreakpoint } from '../../components/_util'; import { DialogType } from '../../types/Dialogs'; import { promptOSAuth } from '../../util/promptOSAuth'; +import type { StateType } from '../reducer'; const DEFAULT_NOTIFICATION_SETTING = 'message'; @@ -147,6 +150,7 @@ export function SmartPreferences(): JSX.Element | null { const { startUpdate } = useUpdatesActions(); const { changeLocation } = useNavActions(); const { showToast } = useToastActions(); + const { internalAddDonationReceipt } = useDonationsActions(); // Selectors @@ -166,6 +170,9 @@ export function SmartPreferences(): JSX.Element | null { const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats); const preferredWidthFromStorage = useSelector(getPreferredLeftPaneWidth); const theme = useSelector(getTheme); + const donationReceipts = useSelector( + (state: StateType) => state.donations.receipts + ); const shouldShowUpdateDialog = dialogType !== DialogType.None; const getPreferredBadge = useSelector(getPreferredBadgeSelector); @@ -833,6 +840,10 @@ export function SmartPreferences(): JSX.Element | null { whoCanFindMe={whoCanFindMe} whoCanSeeMe={whoCanSeeMe} zoomFactor={zoomFactor} + donationReceipts={donationReceipts} + internalAddDonationReceipt={internalAddDonationReceipt} + saveAttachmentToDisk={window.Signal.Migrations.saveAttachmentToDisk} + generateDonationReceiptBlob={generateDonationReceiptBlob} /> ); diff --git a/ts/util/generateDonationReceipt.ts b/ts/util/generateDonationReceipt.ts new file mode 100644 index 0000000000..6e2550b236 --- /dev/null +++ b/ts/util/generateDonationReceipt.ts @@ -0,0 +1,347 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { fabric } from 'fabric'; +import type { DonationReceipt } from '../types/Donations'; +import type { LocalizerType } from '../types/Util'; +import { strictAssert } from './assert'; +import { getDateTimeFormatter } from './formatTimestamp'; + +const SCALING_FACTOR = 4.17; + +// Color constants matching SCSS variables +const COLORS = { + WHITE: '#ffffff', + GRAY_20: '#c6c6c6', + GRAY_45: '#848484', + GRAY_60: '#5e5e5e', + GRAY_90: '#1b1b1b', + GRAY_95: '#121212', +} as const; + +/** + * Helper function to scale font sizes, heights, and letter spacing for the receipt + * @param params - Object containing original values to scale + * @param params.fontSize - Original font size in pixels + * @param params.height - Optional original height/margin/padding in pixels + * @param params.letterSpacing - Optional original letter spacing in pixels + * @returns Scaled values for use in FabricJS + */ +function scaleValues(params: { + fontSize: number; + height?: number; + letterSpacing?: number; +}): { + fontSize: number; + height?: number; + charSpacing?: number; +} { + const result: { + fontSize: number; + height?: number; + charSpacing?: number; + } = { + fontSize: params.fontSize * SCALING_FACTOR, + }; + + if (params.height !== undefined) { + result.height = params.height * SCALING_FACTOR; + } + + if (params.letterSpacing !== undefined) { + // FabricJS charSpacing is in thousandths of em units + // Formula: (letterSpacingPx * 1000) / fontSizePx + // This converts pixel-based letter spacing to em-based units + // For example: -0.13px letter spacing on 12px font = + // (-0.13 * 1000) / 12 = -10.83 thousandths of em + result.charSpacing = (params.letterSpacing * 1000) / params.fontSize; + } + + return result; +} + +const SIGNAL_LOGO_SVG = ` + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export async function generateDonationReceiptBlob( + receipt: DonationReceipt, + i18n: LocalizerType +): Promise { + const width = 2550; + const height = 3300; + const canvas = new fabric.StaticCanvas(null, { + width, + height, + backgroundColor: COLORS.WHITE, + }); + + const fontFamily = 'Inter'; + + const paddingTop = 70 * SCALING_FACTOR; + const paddingX = 66 * SCALING_FACTOR; + const contentWidth = width - paddingX * 2; + + let currentY = paddingTop; + + // Create an image from the SVG + const logo = await new Promise((resolve, reject) => { + const logoDataUrl = `data:image/svg+xml;base64,${btoa(SIGNAL_LOGO_SVG)}`; + fabric.Image.fromURL(logoDataUrl, fabricImg => { + if (!fabricImg) { + reject(new Error('Failed to load logo')); + return; + } + + // Position the logo + fabricImg.set({ + left: paddingX, + top: currentY, + }); + + resolve(fabricImg); + }); + }); + + canvas.add(logo); + + const dateFormatter = getDateTimeFormatter({ + month: 'short', + day: '2-digit', + year: 'numeric', + }); + const dateStr = dateFormatter.format(new Date()); + const dateText = new fabric.Text(dateStr, { + left: width - paddingX, + top: currentY + (logo.height ?? 0), + fontFamily, + fill: COLORS.GRAY_60, + originX: 'right', + originY: 'bottom', + ...scaleValues({ fontSize: 12, letterSpacing: -0.03 }), + }); + canvas.add(dateText); + + currentY += (logo.height ?? 0) + 16 * SCALING_FACTOR; + + const divider1 = new fabric.Rect({ + left: paddingX, + top: currentY, + width: contentWidth, + height: 1 * SCALING_FACTOR, + fill: COLORS.GRAY_20, + }); + canvas.add(divider1); + strictAssert(divider1.height != null, 'Divider1 height must be defined'); + currentY += divider1.height; + + currentY += 167; + const title = new fabric.Text(i18n('icu:DonationReceipt__title'), { + left: paddingX, + top: currentY, + fontFamily, + fill: COLORS.GRAY_90, + ...scaleValues({ fontSize: 20, letterSpacing: -0.34 }), + }); + canvas.add(title); + strictAssert(title.height != null, 'Title height must be defined'); + currentY += title.height + 29 * SCALING_FACTOR; + + // Amount section + const amountLabel = new fabric.Text( + i18n('icu:DonationReceipt__amount-label'), + { + left: paddingX, + top: currentY, + fontFamily, + fill: COLORS.GRAY_90, + ...scaleValues({ fontSize: 14, letterSpacing: -0.34 }), + } + ); + canvas.add(amountLabel); + + // Format currency + const preferredSystemLocales = + window.SignalContext.getPreferredSystemLocales(); + const localeOverride = window.SignalContext.getLocaleOverride(); + const locales = + localeOverride != null ? [localeOverride] : preferredSystemLocales; + + const formatter = new Intl.NumberFormat(locales, { + style: 'currency', + currency: receipt.currencyType, + }); + const amountStr = formatter.format(receipt.paymentAmount / 100); + const amountValue = new fabric.Text(amountStr, { + left: width - paddingX, + top: currentY, + fontFamily, + fill: COLORS.GRAY_90, + originX: 'right', + ...scaleValues({ fontSize: 14, letterSpacing: -0.34 }), + }); + canvas.add(amountValue); + + strictAssert( + amountLabel.height != null, + 'Amount label height must be defined' + ); + strictAssert( + amountValue.height != null, + 'Amount value height must be defined' + ); + currentY += + Math.max(amountLabel.height, amountValue.height) + 25 * SCALING_FACTOR; + + const boldDivider = new fabric.Rect({ + left: paddingX, + top: currentY, + width: contentWidth, + height: 1 * SCALING_FACTOR, + fill: COLORS.GRAY_90, + }); + canvas.add(boldDivider); + strictAssert( + boldDivider.height != null, + 'Bold divider height must be defined' + ); + currentY += boldDivider.height; + + // Details section (margin-top: 50px) + currentY += 12 * SCALING_FACTOR; + + // Detail row 1 - Type (padding: 50px 0) + currentY += 12 * SCALING_FACTOR; + const typeLabel = new fabric.Text(i18n('icu:DonationReceipt__type-label'), { + left: paddingX, + top: currentY, + fontFamily, + fill: COLORS.GRAY_95, + ...scaleValues({ fontSize: 14, letterSpacing: -0.34 }), + }); + canvas.add(typeLabel); + + strictAssert(typeLabel.height != null, 'Type label height must be defined'); + currentY += 4 * SCALING_FACTOR + typeLabel.height; // margin-bottom + actual height + const typeValue = new fabric.Text( + i18n('icu:DonationReceipt__type-value--one-time'), + { + left: paddingX, + top: currentY, + fontFamily, + fill: COLORS.GRAY_45, + ...scaleValues({ fontSize: 12, letterSpacing: -0.08 }), + } + ); + canvas.add(typeValue); + strictAssert(typeValue.height != null, 'Type value height must be defined'); + currentY += typeValue.height + 50; // actual height + bottom padding + + const rowDivider = new fabric.Rect({ + left: paddingX, + top: currentY, + width: contentWidth, + height: 1 * SCALING_FACTOR, + fill: COLORS.GRAY_20, + }); + canvas.add(rowDivider); + strictAssert(rowDivider.height != null, 'Row divider height must be defined'); + currentY += rowDivider.height; + + // Detail row 2 - Date Paid + currentY += 12 * SCALING_FACTOR; + const dateLabel = new fabric.Text( + i18n('icu:DonationReceipt__date-paid-label'), + { + left: paddingX, + top: currentY, + fontFamily, + fill: COLORS.GRAY_95, + ...scaleValues({ fontSize: 14, letterSpacing: -0.34 }), + } + ); + canvas.add(dateLabel); + + strictAssert(dateLabel.height != null, 'Date label height must be defined'); + currentY += 4 * SCALING_FACTOR + dateLabel.height; + const paymentDateFormatter = getDateTimeFormatter({ + month: 'short', + day: 'numeric', + year: 'numeric', + }); + const paymentDate = paymentDateFormatter.format(new Date(receipt.timestamp)); + const dateValue = new fabric.Text(paymentDate, { + left: paddingX, + top: currentY, + fontFamily, + fill: COLORS.GRAY_45, + ...scaleValues({ fontSize: 12, letterSpacing: -0.08 }), + }); + canvas.add(dateValue); + strictAssert(dateValue.height != null, 'Date value height must be defined'); + currentY += dateValue.height + 50; + + currentY += 10 * SCALING_FACTOR; + const footerText = i18n('icu:DonationReceipt__footer-text'); + + const footer = new fabric.Textbox(footerText, { + left: paddingX, + top: currentY, + width: contentWidth, + fontFamily, + fill: COLORS.GRAY_60, + lineHeight: 1.45, + ...scaleValues({ fontSize: 11 }), + }); + canvas.add(footer); + + canvas.renderAll(); + + // Convert canvas to PNG blob + // First, get the canvas as a data URL (base64 encoded string) + const dataURL = canvas.toDataURL({ + format: 'png', + multiplier: 1, + }); + + // Extract the base64 encoded data from the data URL + // Data URL format: "data:image/png;base64,iVBORw0KGgoAAAANS..." + const base64Data = dataURL.split(',')[1]; + + // Decode the base64 string to binary data + const binaryString = atob(base64Data); + + // Convert the binary string directly to a typed array + const byteArray = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i += 1) { + byteArray[i] = binaryString.charCodeAt(i); + } + + const blob = new Blob([byteArray], { type: 'image/png' }); + + return blob; +} diff --git a/ts/windows/main/preload_test.ts b/ts/windows/main/preload_test.ts index f5800ccf6b..6f18039ce2 100644 --- a/ts/windows/main/preload_test.ts +++ b/ts/windows/main/preload_test.ts @@ -125,6 +125,9 @@ window.testUtilities = { }, stories: [], storyDistributionLists: [], + donations: { + receipts: [], + }, stickers: { installedPack: null, packs: {},