diff --git a/bundles/processing/modules/donations/DonationRow.tsx b/bundles/processing/modules/donations/DonationRow.tsx index ea0973d9d..1322b5107 100644 --- a/bundles/processing/modules/donations/DonationRow.tsx +++ b/bundles/processing/modules/donations/DonationRow.tsx @@ -15,13 +15,14 @@ const UNKNOWN_DONOR_NAME = '(unknown)'; interface BidsRowProps { bids: DonationBid[]; + currency: string; } function BidsRow(props: BidsRowProps) { - const { bids } = props; + const { bids, currency } = props; if (bids.length === 0) return null; - const bidNames = bids.map(bid => `${bid.bid_name} (${CurrencyUtils.asCurrency(bid.amount)})`); + const bidNames = bids.map(bid => `${bid.bid_name} (${CurrencyUtils.asCurrency(bid.amount, { currency })})`); return ( @@ -76,7 +77,7 @@ export default function DonationRow(props: DonationRowProps) { canDrop: checkDrop, } = props; - const amount = CurrencyUtils.asCurrency(donation.amount); + const amount = CurrencyUtils.asCurrency(donation.amount, { currency: donation.currency }); const donationTitle = ( {amount} @@ -136,7 +137,7 @@ export default function DonationRow(props: DonationRowProps) { {renderActions(donation)} - {showBids ? : null} + {showBids ? : null} {donationComment} diff --git a/bundles/processing/modules/donations/ModCommentModal.tsx b/bundles/processing/modules/donations/ModCommentModal.tsx index f7c90a88e..63d6f92f7 100644 --- a/bundles/processing/modules/donations/ModCommentModal.tsx +++ b/bundles/processing/modules/donations/ModCommentModal.tsx @@ -14,7 +14,7 @@ import styles from '../donation-groups/CreateEditDonationGroupModal.mod.css'; function renderDonationHeader(donation: Donation) { const timestamp = TimeUtils.parseTimestamp(donation.timereceived); - const amount = CurrencyUtils.asCurrency(donation.amount); + const amount = CurrencyUtils.asCurrency(donation.amount, { currency: donation.currency }); return ( diff --git a/bundles/processing/modules/event/EventTotalDisplay.tsx b/bundles/processing/modules/event/EventTotalDisplay.tsx index 7e93ef6a6..89172dd91 100644 --- a/bundles/processing/modules/event/EventTotalDisplay.tsx +++ b/bundles/processing/modules/event/EventTotalDisplay.tsx @@ -3,15 +3,10 @@ import { useQuery } from 'react-query'; import { Header, Stack, Text } from '@spyrothon/sparx'; import APIClient from '@public/apiv2/APIClient'; +import * as CurrencyUtils from '@public/util/currency'; import useEventTotalStore, { setEventTotalIfNewer } from './EventTotalStore'; -const currencyFormat = Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumIntegerDigits: 1, - minimumFractionDigits: 2, -}); const numberFormat = Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }); interface EventTotalDisplayProps { @@ -50,7 +45,13 @@ export default function EventTotalDisplay(props: EventTotalDisplayProps) {
Total Raised
- {isLoading ? '--' : currencyFormat.format(total)} + + {isLoading + ? '--' + : CurrencyUtils.asCurrency(total, { + currency: event?.paypalcurrency ?? 'USD', + })} +
); diff --git a/bundles/processing/modules/processing/ActionLog.tsx b/bundles/processing/modules/processing/ActionLog.tsx index b8bc567d1..98013eea8 100644 --- a/bundles/processing/modules/processing/ActionLog.tsx +++ b/bundles/processing/modules/processing/ActionLog.tsx @@ -46,7 +46,7 @@ function ActionEntry({ action }: { action: HistoryAction }) { }, ); - const amount = CurrencyUtils.asCurrency(donation.amount); + const amount = CurrencyUtils.asCurrency(donation.amount, { currency: donation.currency }); return (
diff --git a/bundles/processing/modules/reading/ReadingDonationRowPopout.tsx b/bundles/processing/modules/reading/ReadingDonationRowPopout.tsx index 9cc8de805..70d626c6c 100644 --- a/bundles/processing/modules/reading/ReadingDonationRowPopout.tsx +++ b/bundles/processing/modules/reading/ReadingDonationRowPopout.tsx @@ -31,7 +31,7 @@ export default function ReadingDonationRowPopout(props: ReadingDonationRowPopout const donation = useDonation(donationId); const { groups, removeDonationFromAllGroups } = useDonationGroupsStore(); - const amount = CurrencyUtils.asCurrency(donation.amount); + const amount = CurrencyUtils.asCurrency(donation.amount, { currency: donation.currency }); const donationLink = useAdminRoute(AdminRoutes.DONATION(donation.id)); const donorLink = useAdminRoute(AdminRoutes.DONOR(donation.donor)); const canEditDonors = usePermission('tracker.change_donor'); diff --git a/bundles/public/apiv2/APITypes.tsx b/bundles/public/apiv2/APITypes.tsx index 8fb131ec0..9913b881f 100644 --- a/bundles/public/apiv2/APITypes.tsx +++ b/bundles/public/apiv2/APITypes.tsx @@ -35,6 +35,7 @@ export type Event = { hashtag: string; date: string; timezone: string; + paypalcurrency: string; use_one_step_screening: boolean; amount?: number; donation_count?: number; diff --git a/bundles/public/util/currency.ts b/bundles/public/util/currency.ts index 73c6bd078..fcb9fdb6f 100644 --- a/bundles/public/util/currency.ts +++ b/bundles/public/util/currency.ts @@ -1,5 +1,41 @@ -export function asCurrency(amount: string | number) { - return `$${Number(amount).toFixed(2)}`; +// This type enforces that consumers pass in the `currency` they want to display. +interface CurrencyOptions extends Omit { + currency: string; +} + +export function asCurrency(amount: string | number, options: CurrencyOptions) { + const formatOptions = { + style: 'currency', + minimumIntegerDigits: 1, + minimumFractionDigits: 2, + ...options, + }; + + // We need to set minimumFractionDigits to 0 when the max is 0 + if (formatOptions.maximumFractionDigits === 0) { + formatOptions.minimumFractionDigits = 0; + } + + // `en-US` is hardcoded here because we don't actually localize the frontend currently. + const formatter = new Intl.NumberFormat('en-US', formatOptions); + + return formatter.format(Number(amount)); +} + +export function getCurrencySymbol(currency: string): string { + try { + const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency, currencyDisplay: 'narrowSymbol' }); + + for (const part of formatter.formatToParts(0)) { + if (part.type === 'currency') return part.value; + } + } catch (e: unknown) { + // Ignored: RangeError: invalid currency code in NumberFormat() + } + + // If there was no currency symbol in the formatted string, then we can assume that + // the language does not expect there to be a symbol around the currency value. + return ''; } export function parseCurrency(amount?: string) { diff --git a/bundles/public/util/currencySpec.ts b/bundles/public/util/currencySpec.ts new file mode 100644 index 000000000..b93913443 --- /dev/null +++ b/bundles/public/util/currencySpec.ts @@ -0,0 +1,47 @@ +import * as CurrencyUtils from './currency'; + +describe('CurrencyUtils', () => { + describe('asCurrency', () => { + it('formats euros', () => { + const result = CurrencyUtils.asCurrency(123456.789, { currency: 'EUR', maximumFractionDigits: 0 }); + + expect(result).toEqual('€123,457'); + }); + + it('formats with minimum digits', () => { + const result = CurrencyUtils.asCurrency(20, { currency: 'USD', minimumFractionDigits: 2 }); + + expect(result).toEqual('$20.00'); + }); + + it('formats currency with full name', () => { + const result = CurrencyUtils.asCurrency(20, { currency: 'UAH', currencyDisplay: 'name' }); + + expect(result).toEqual('20.00 Ukrainian hryvnias'); + }); + }); + + describe('getCurrencySymbol', () => { + it('returns dollar symbol for USD', () => { + const result = CurrencyUtils.getCurrencySymbol('USD'); + + expect(result).toEqual('$'); + }); + it('returns euro symbol for EUR', () => { + const result = CurrencyUtils.getCurrencySymbol('EUR'); + + expect(result).toEqual('€'); + }); + it("returns input currency when it's 3 letters", () => { + const result = CurrencyUtils.getCurrencySymbol('GDQ'); + + // This is how the api works for some reason. GDQ is not a valid ISO-4217 currency, yet the api returns it. + expect(result).toEqual('GDQ'); + }); + it('returns blank string for wrong input', () => { + const result = CurrencyUtils.getCurrencySymbol('Invalid currency!'); + + expect(result).toEqual(''); + }); + }); +}); diff --git a/bundles/tracker/donation/__tests__/validateBid.spec.ts b/bundles/tracker/donation/__tests__/validateBid.spec.ts index b4334877e..948a70265 100644 --- a/bundles/tracker/donation/__tests__/validateBid.spec.ts +++ b/bundles/tracker/donation/__tests__/validateBid.spec.ts @@ -58,7 +58,7 @@ describe('validateBid', () => { amount: 2.5, }; - const validation = validateBid(bid, basicIncentive, donation, [], false, false); + const validation = validateBid('USD', bid, basicIncentive, donation, [], false, false); expect(validation.valid).toBe(true); expect(validation.errors.length).toEqual(0); }); @@ -71,9 +71,12 @@ describe('validateBid', () => { customoptionname: 'test', }; - const validation = validateBid(bid, basicIncentive, donation, [], false, false); + const validation = validateBid('USD', bid, basicIncentive, donation, [], false, false); expect(validation.valid).toBe(false); - expect(validation.errors).toContain({ field: 'amount', message: BidErrors.AMOUNT_MINIMUM(BID_MINIMUM_AMOUNT) }); + expect(validation.errors).toContain({ + field: 'amount', + message: BidErrors.AMOUNT_MINIMUM(BID_MINIMUM_AMOUNT, 'USD'), + }); }); it('passes when amount equals allowed minimum', () => { @@ -82,7 +85,7 @@ describe('validateBid', () => { amount: BID_MINIMUM_AMOUNT, }; - const validation = validateBid(bid, basicIncentive, donation, [], false, false); + const validation = validateBid('USD', bid, basicIncentive, donation, [], false, false); expect(validation.valid).toBe(true); expect(validation.errors.length).toEqual(0); }); @@ -94,7 +97,7 @@ describe('validateBid', () => { amount: max, }; - const validation = validateBid(bid, basicIncentive, donation, [], false, false); + const validation = validateBid('USD', bid, basicIncentive, donation, [], false, false); expect(validation.valid).toBe(true); expect(validation.errors.length).toEqual(0); }); @@ -106,9 +109,9 @@ describe('validateBid', () => { amount: max + 1, }; - const validation = validateBid(bid, basicIncentive, donation, [], false, false); + const validation = validateBid('USD', bid, basicIncentive, donation, [], false, false); expect(validation.valid).toBe(false); - expect(validation.errors).toContain({ field: 'amount', message: BidErrors.AMOUNT_MAXIMUM(max) }); + expect(validation.errors).toContain({ field: 'amount', message: BidErrors.AMOUNT_MAXIMUM(max, 'USD') }); }); }); @@ -119,7 +122,7 @@ describe('validateBid', () => { amount: 2.5, }; - const validation = validateBid(bid, incentiveWithOptions, donation, [], true, false); + const validation = validateBid('USD', bid, incentiveWithOptions, donation, [], true, false); expect(validation.valid).toBe(false); expect(validation.errors).toContain({ field: 'incentiveId', message: BidErrors.NO_CHOICE }); }); @@ -130,7 +133,7 @@ describe('validateBid', () => { amount: 2.5, }; - const validation = validateBid(bid, incentiveWithOptions, donation, [], true, true); + const validation = validateBid('USD', bid, incentiveWithOptions, donation, [], true, true); expect(validation.valid).toBe(true); expect(validation.errors.length).toEqual(0); }); @@ -142,7 +145,7 @@ describe('validateBid', () => { customoptionname: 'test', }; - const validation = validateBid(bid, incentiveWithOptions, donation, [], true, true, true); + const validation = validateBid('USD', bid, incentiveWithOptions, donation, [], true, true, true); expect(validation.valid).toBe(true); expect(validation.errors.length).toEqual(0); }); @@ -154,7 +157,7 @@ describe('validateBid', () => { customoptionname: '', }; - const validation = validateBid(bid, incentiveWithOptions, donation, [], true, true, true); + const validation = validateBid('USD', bid, incentiveWithOptions, donation, [], true, true, true); expect(validation.valid).toBe(false); expect(validation.errors).toContain({ field: 'new option', @@ -169,7 +172,7 @@ describe('validateBid', () => { customoptionname: 'this is too long to be allowable clearly', }; - const validation = validateBid(bid, incentiveWithOptions, donation, [], true, true, true); + const validation = validateBid('USD', bid, incentiveWithOptions, donation, [], true, true, true); expect(validation.valid).toBe(false); expect(validation.errors).toContain({ field: 'new option', diff --git a/bundles/tracker/donation/__tests__/validateDonation.spec.ts b/bundles/tracker/donation/__tests__/validateDonation.spec.ts index 22c0aa8c4..4f236136a 100644 --- a/bundles/tracker/donation/__tests__/validateDonation.spec.ts +++ b/bundles/tracker/donation/__tests__/validateDonation.spec.ts @@ -4,6 +4,7 @@ import validateDonation, { DonationErrors } from '../validateDonation'; const eventDetails = { csrfToken: 'testing', + currency: 'USD', receiverName: 'a beneficiary', prizesUrl: 'https://example.com/prizes', donateUrl: 'https://example.com/donate', @@ -57,7 +58,7 @@ describe('validateDonation', () => { expect(validation.valid).toBe(false); expect(validation.errors).toContain({ field: 'amount', - message: DonationErrors.AMOUNT_MINIMUM(eventDetails.minimumDonation), + message: DonationErrors.AMOUNT_MINIMUM(eventDetails.minimumDonation, 'USD'), }); }); @@ -74,7 +75,7 @@ describe('validateDonation', () => { expect(validation.valid).toBe(false); expect(validation.errors).toContain({ field: 'amount', - message: DonationErrors.AMOUNT_MAXIMUM(eventDetails.maximumDonation), + message: DonationErrors.AMOUNT_MAXIMUM(eventDetails.maximumDonation, 'USD'), }); }); }); diff --git a/bundles/tracker/donation/components/Donate.tsx b/bundles/tracker/donation/components/Donate.tsx index 9003aa004..18836556e 100644 --- a/bundles/tracker/donation/components/Donate.tsx +++ b/bundles/tracker/donation/components/Donate.tsx @@ -66,7 +66,7 @@ const Donate = (props: DonateProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventId]); - const { receiverName, donateUrl, minimumDonation, maximumDonation, step } = eventDetails; + const { currency, receiverName, donateUrl, minimumDonation, maximumDonation, step } = eventDetails; const { name, email, wantsEmails, amount, comment } = donation; const updateDonation = React.useCallback( @@ -152,9 +152,10 @@ const Donate = (props: DonateProps) => { name="amount" value={amount} label="Amount" + currency={currency} hint={ - Minimum donation is {CurrencyUtils.asCurrency(minimumDonation)} + Minimum donation is {CurrencyUtils.asCurrency(minimumDonation, { currency })} } size={CurrencyInput.Sizes.LARGE} @@ -170,7 +171,7 @@ const Donate = (props: DonateProps) => { key={amountPreset} look={Button.Looks.OUTLINED} onClick={updateAmountPreset(amountPreset)}> - ${amountPreset} + {CurrencyUtils.asCurrency(amountPreset, { currency })} ))}
@@ -214,7 +215,7 @@ const Donate = (props: DonateProps) => { fullwidth onClick={handleSubmit} data-testid="donation-submit"> - Donate {amount != null ? CurrencyUtils.asCurrency(amount) : null} + Donate {amount != null ? CurrencyUtils.asCurrency(amount, { currency }) : null} diff --git a/bundles/tracker/donation/components/DonateInitializer.tsx b/bundles/tracker/donation/components/DonateInitializer.tsx index 39cbe1a77..7cfe83e5a 100644 --- a/bundles/tracker/donation/components/DonateInitializer.tsx +++ b/bundles/tracker/donation/components/DonateInitializer.tsx @@ -56,6 +56,7 @@ type DonateInitializerProps = { }; initialIncentives: InitialIncentive[]; event: { + paypalcurrency: string; receivername: string; }; step: number; @@ -118,6 +119,7 @@ const DonateInitializer = (props: DonateInitializerProps) => { dispatch( EventDetailsActions.loadEventDetails({ csrfToken, + currency: event.paypalcurrency, receiverName: event.receivername, prizesUrl, donateUrl, diff --git a/bundles/tracker/donation/components/DonationBidForm.tsx b/bundles/tracker/donation/components/DonationBidForm.tsx index 62908c711..61e96e558 100644 --- a/bundles/tracker/donation/components/DonationBidForm.tsx +++ b/bundles/tracker/donation/components/DonationBidForm.tsx @@ -32,7 +32,8 @@ type DonationBidFormProps = { const DonationBidForm = (props: DonationBidFormProps) => { const { incentiveId, step, total: donationTotal, className, onSubmit } = props; - const { incentive, bidChoices, donation, bids, allocatedTotal } = useSelector((state: StoreState) => ({ + const { currency, incentive, bidChoices, donation, bids, allocatedTotal } = useSelector((state: StoreState) => ({ + currency: EventDetailsStore.getEventCurrency(state), incentive: EventDetailsStore.getIncentive(state, incentiveId), bidChoices: EventDetailsStore.getChildIncentives(state, incentiveId), donation: DonationStore.getDonation(state), @@ -41,7 +42,7 @@ const DonationBidForm = (props: DonationBidFormProps) => { })); const remainingDonationTotal = donationTotal - allocatedTotal; - const remainingDonationTotalString = CurrencyUtils.asCurrency(remainingDonationTotal); + const remainingDonationTotalString = CurrencyUtils.asCurrency(remainingDonationTotal, { currency }); const [allocatedAmount, setAllocatedAmount] = React.useState(remainingDonationTotal); const [selectedChoiceId, setSelectedChoiceId] = React.useState(undefined); @@ -57,6 +58,7 @@ const DonationBidForm = (props: DonationBidFormProps) => { const bidValidation = React.useMemo( () => validateBid( + currency, { incentiveId: selectedChoiceId != null ? selectedChoiceId : incentiveId, amount: allocatedAmount, @@ -70,6 +72,7 @@ const DonationBidForm = (props: DonationBidFormProps) => { customOptionSelected, ), [ + currency, selectedChoiceId, incentiveId, allocatedAmount, @@ -115,7 +118,8 @@ const DonationBidForm = (props: DonationBidFormProps) => { Current Raised Amount:{' '} - {CurrencyUtils.asCurrency(incentive.amount)} / {CurrencyUtils.asCurrency(incentive.goal)} + {CurrencyUtils.asCurrency(incentive.amount, { currency })} /{' '} + {CurrencyUtils.asCurrency(incentive.goal, { currency })} @@ -125,6 +129,7 @@ const DonationBidForm = (props: DonationBidFormProps) => { value={Math.min(allocatedAmount, remainingDonationTotal)} name="incentiveBidAmount" label="Amount to put towards incentive" + currency={currency} hint={ You have {remainingDonationTotalString} remaining. diff --git a/bundles/tracker/donation/components/DonationBids.tsx b/bundles/tracker/donation/components/DonationBids.tsx index 59a15e348..db15d3a59 100644 --- a/bundles/tracker/donation/components/DonationBids.tsx +++ b/bundles/tracker/donation/components/DonationBids.tsx @@ -29,8 +29,8 @@ type BidItemProps = { const BidItem = (props: BidItemProps) => { const { bid, incentive, onDelete, errors } = props; - - const bidAmount = CurrencyUtils.asCurrency(bid.amount); + const currency = useSelector(EventDetailsStore.getEventCurrency); + const bidAmount = CurrencyUtils.asCurrency(bid.amount, { currency }); return (
diff --git a/bundles/tracker/donation/components/DonationPrizes.tsx b/bundles/tracker/donation/components/DonationPrizes.tsx index fa1397aba..bc3e0cecf 100644 --- a/bundles/tracker/donation/components/DonationPrizes.tsx +++ b/bundles/tracker/donation/components/DonationPrizes.tsx @@ -20,6 +20,7 @@ type PrizeProps = { const PrizeRow = (props: PrizeProps) => { const { eventId, prize } = props; + const currency = useSelector(EventDetailsStore.getEventCurrency); return (
@@ -27,7 +28,7 @@ const PrizeRow = (props: PrizeProps) => { {prize.url != null ? {prize.name} : prize.name} - {CurrencyUtils.asCurrency(prize.minimumbid)}{' '} + {CurrencyUtils.asCurrency(prize.minimumbid, { currency })}{' '} {prize.sumdonations ? 'Total Donations' : 'Minimum Single Donation'}
diff --git a/bundles/tracker/donation/validateBid.ts b/bundles/tracker/donation/validateBid.ts index d8b223969..9585faff6 100644 --- a/bundles/tracker/donation/validateBid.ts +++ b/bundles/tracker/donation/validateBid.ts @@ -12,14 +12,17 @@ export const BidErrors = { NO_CHOICE: 'Bid must select a choice', NO_AMOUNT: 'Bid amount is required', - AMOUNT_MINIMUM: (min: number) => `Bid amount must be greater than (${CurrencyUtils.asCurrency(min)})`, - AMOUNT_MAXIMUM: (max: number) => `Amount is larger than remaining total (${CurrencyUtils.asCurrency(max)}).`, + AMOUNT_MINIMUM: (min: number, currency: string) => + `Bid amount must be greater than (${CurrencyUtils.asCurrency(min, { currency })})`, + AMOUNT_MAXIMUM: (max: number, currency: string) => + `Amount is larger than remaining total (${CurrencyUtils.asCurrency(max, { currency })}).`, NO_CUSTOM_CHOICE: 'New option does not have a value', CUSTOM_CHOICE_LENGTH: (maxLength: number) => `New choice must be less than ${maxLength} characters`, }; export default function validateBid( + currency: string, newBid: Partial, incentive: Incentive, donation: Donation, @@ -48,14 +51,14 @@ export default function validateBid( if (newBid.amount < BID_MINIMUM_AMOUNT) { errors.push({ field: 'amount', - message: BidErrors.AMOUNT_MINIMUM(BID_MINIMUM_AMOUNT), + message: BidErrors.AMOUNT_MINIMUM(BID_MINIMUM_AMOUNT, currency), }); } if (newBid.amount > remainingTotal) { errors.push({ field: 'amount', - message: BidErrors.AMOUNT_MAXIMUM(remainingTotal), + message: BidErrors.AMOUNT_MAXIMUM(remainingTotal, currency), }); } } diff --git a/bundles/tracker/donation/validateDonation.ts b/bundles/tracker/donation/validateDonation.ts index 74c32b97a..77cc76a64 100644 --- a/bundles/tracker/donation/validateDonation.ts +++ b/bundles/tracker/donation/validateDonation.ts @@ -11,8 +11,10 @@ import { Bid, Donation, Validation } from './DonationTypes'; export const DonationErrors = { NO_AMOUNT: 'Donation amount is not set', - AMOUNT_MINIMUM: (min: number) => `Donation amount is below the allowed minimum (${CurrencyUtils.asCurrency(min)})`, - AMOUNT_MAXIMUM: (max: number) => `Donation amount is above the allowed maximum (${CurrencyUtils.asCurrency(max)})`, + AMOUNT_MINIMUM: (min: number, currency: string) => + `Donation amount is below the allowed minimum (${CurrencyUtils.asCurrency(min, { currency })})`, + AMOUNT_MAXIMUM: (max: number, currency: string) => + `Donation amount is above the allowed maximum (${CurrencyUtils.asCurrency(max, { currency })})`, TOO_MANY_BIDS: (maxBids: number) => `Only ${maxBids} bids can be set per donation.`, BID_SUM_EXCEEDS_TOTAL: 'Sum of bid amounts exceeds donation total.', @@ -30,14 +32,14 @@ export default function validateDonation(eventDetails: EventDetails, donation: D if (donation.amount < eventDetails.minimumDonation) { errors.push({ field: 'amount', - message: DonationErrors.AMOUNT_MINIMUM(eventDetails.minimumDonation), + message: DonationErrors.AMOUNT_MINIMUM(eventDetails.minimumDonation, eventDetails.currency), }); } if (donation.amount > eventDetails.maximumDonation) { errors.push({ field: 'amount', - message: DonationErrors.AMOUNT_MAXIMUM(eventDetails.maximumDonation), + message: DonationErrors.AMOUNT_MAXIMUM(eventDetails.maximumDonation, eventDetails.currency), }); } diff --git a/bundles/tracker/event_details/EventDetailsReducer.ts b/bundles/tracker/event_details/EventDetailsReducer.ts index 269835ffc..b36de6273 100644 --- a/bundles/tracker/event_details/EventDetailsReducer.ts +++ b/bundles/tracker/event_details/EventDetailsReducer.ts @@ -8,6 +8,7 @@ type EventDetailsState = EventDetails; const initialState: EventDetailsState = { csrfToken: '', + currency: 'USD', // Default to USD to make tests happy receiverName: '', prizesUrl: '', donateUrl: '', diff --git a/bundles/tracker/event_details/EventDetailsStore.ts b/bundles/tracker/event_details/EventDetailsStore.ts index 2a8b47610..26d6d109e 100644 --- a/bundles/tracker/event_details/EventDetailsStore.ts +++ b/bundles/tracker/event_details/EventDetailsStore.ts @@ -10,6 +10,8 @@ export const getPrizes = (state: StoreState) => state.eventDetails.prizes; export const getEventDetails = getEventDetailsState; +export const getEventCurrency = createSelector([getEventDetailsState], state => state.currency); + export const getIncentives = createSelector([getIncentivesById], incentivesById => Object.values(incentivesById)); export const getIncentive = createSelector( diff --git a/bundles/tracker/event_details/EventDetailsTypes.ts b/bundles/tracker/event_details/EventDetailsTypes.ts index 1613d3e56..32f9874b8 100644 --- a/bundles/tracker/event_details/EventDetailsTypes.ts +++ b/bundles/tracker/event_details/EventDetailsTypes.ts @@ -30,6 +30,7 @@ export type Prize = { export type EventDetails = { csrfToken: string; + currency: string; receiverName: string; prizesUrl: string; donateUrl: string; diff --git a/bundles/tracker/prizes/components/Prize.tsx b/bundles/tracker/prizes/components/Prize.tsx index bc7799542..42be784c3 100644 --- a/bundles/tracker/prizes/components/Prize.tsx +++ b/bundles/tracker/prizes/components/Prize.tsx @@ -48,11 +48,11 @@ const PrizeDonateButton = ({ prize, now, onClick }: PrizeDonateButtonProps) => { return null; }; -function getPrizeDetails(prize: PrizeTypes.Prize) { +function getPrizeDetails(prize: PrizeTypes.Prize, currency: string) { return [ { name: 'Estimated Value', - value: prize.estimatedValue != null ? CurrencyUtils.asCurrency(prize.estimatedValue) : undefined, + value: prize.estimatedValue != null ? CurrencyUtils.asCurrency(prize.estimatedValue, { currency }) : undefined, }, { name: 'Opening Run', @@ -91,11 +91,13 @@ const Prize = (props: PrizeProps) => { const [prizeError, setPrizeError] = useState(false); const setPrizeErrorTrue = useCallback(() => setPrizeError(true), []); const dispatch = useDispatch(); - const { event, eventId, prize } = useSelector((state: StoreState) => { + const { currency, event, eventId, prize } = useSelector((state: StoreState) => { const prize = PrizeStore.getPrize(state, { prizeId }); const event = prize != null ? EventStore.getEvent(state, { eventId: prize.eventId }) : undefined; return { + // Fall back to USD in case the event is undefined + currency: event?.paypalCurrency || 'USD', event, eventId: prize != null ? prize.eventId : undefined, prize, @@ -137,7 +139,7 @@ const Prize = (props: PrizeProps) => { ); - const prizeDetails = getPrizeDetails(prize); + const prizeDetails = getPrizeDetails(prize, currency); const prizeImage = prizeError ? null : PrizeUtils.getPrimaryImage(prize); return ( @@ -175,7 +177,7 @@ const Prize = (props: PrizeProps) => { ) : null} - {CurrencyUtils.asCurrency(prize.minimumBid)} + {CurrencyUtils.asCurrency(prize.minimumBid, { currency })} {prize.sumDonations ? 'Total Donations' : 'Minimum Single Donation'} diff --git a/bundles/tracker/prizes/components/PrizeCard.tsx b/bundles/tracker/prizes/components/PrizeCard.tsx index 05ba0a800..bedc39f30 100644 --- a/bundles/tracker/prizes/components/PrizeCard.tsx +++ b/bundles/tracker/prizes/components/PrizeCard.tsx @@ -22,11 +22,12 @@ import styles from './PrizeCard.mod.css'; type PrizeCardProps = { // TODO: should be a number prizeId: string; + currency: string; className?: string; }; const PrizeCard = (props: PrizeCardProps) => { - const { prizeId, className } = props; + const { prizeId, className, currency } = props; const now = TimeUtils.getNowLocal(); const [prizeError, setPrizeError] = useState(false); @@ -82,7 +83,7 @@ const PrizeCard = (props: PrizeCardProps) => { ) : null} - {CurrencyUtils.asCurrency(prize.minimumBid)} + {CurrencyUtils.asCurrency(prize.minimumBid, { currency })}
{prize.sumDonations ? 'Total Donations' : 'Minimum Donation'}
diff --git a/bundles/tracker/prizes/components/PrizeCardSpec.tsx b/bundles/tracker/prizes/components/PrizeCardSpec.tsx index 2d4030c1e..ff55e3005 100644 --- a/bundles/tracker/prizes/components/PrizeCardSpec.tsx +++ b/bundles/tracker/prizes/components/PrizeCardSpec.tsx @@ -24,6 +24,7 @@ describe('PrizeCard', () => { function render(props = {}) { const defaultProps = { prizeId: '123', + currency: 'USD', }; return renderWithState( diff --git a/bundles/tracker/prizes/components/Prizes.tsx b/bundles/tracker/prizes/components/Prizes.tsx index fd9c4f575..13d3e19f8 100644 --- a/bundles/tracker/prizes/components/Prizes.tsx +++ b/bundles/tracker/prizes/components/Prizes.tsx @@ -30,10 +30,11 @@ const FEATURED_SECTION_LIMIT = 6; type PrizeGridProps = { prizes: Prize[]; name: string; + currency: string; }; const PrizeGrid = (props: PrizeGridProps) => { - const { prizes, name } = props; + const { prizes, name, currency } = props; return (
@@ -42,7 +43,7 @@ const PrizeGrid = (props: PrizeGridProps) => {
{prizes.map(prize => ( - + ))}
@@ -105,11 +106,11 @@ const Prizes = (props: PrizesProps) => { {closingPrizes.length > 0 && ( - +
)} - +
) : (
diff --git a/bundles/uikit/CurrencyInput.tsx b/bundles/uikit/CurrencyInput.tsx index f91bfea33..e8fed9fde 100644 --- a/bundles/uikit/CurrencyInput.tsx +++ b/bundles/uikit/CurrencyInput.tsx @@ -2,11 +2,14 @@ import * as React from 'react'; import classNames from 'classnames'; import ReactNumeric from 'react-numeric'; +import * as CurrencyUtils from '@public/util/currency'; + import InputWrapper, { InputWrapperPassthroughProps } from './InputWrapper'; import styles from './CurrencyInput.mod.css'; -type CurrencyInputProps = InputWrapperPassthroughProps & { +type CurrencyInputProps = Omit & { + currency: string; value?: number; placeholder?: string; disabled?: boolean; @@ -20,6 +23,7 @@ type CurrencyInputProps = InputWrapperPassthroughProps & { // - entering thousandths or beyond // - formatting according to user's locale const CurrencyInput = (props: CurrencyInputProps) => { + const currencySymbol = CurrencyUtils.getCurrencySymbol(props.currency); const { size = InputWrapper.Sizes.NORMAL, value, @@ -28,7 +32,7 @@ const CurrencyInput = (props: CurrencyInputProps) => { name, label, hint, - leader = '$', + leader = currencySymbol, trailer, marginless = false, className, diff --git a/tracker/api/serializers.py b/tracker/api/serializers.py index 82f6a5433..bed1e59cb 100644 --- a/tracker/api/serializers.py +++ b/tracker/api/serializers.py @@ -662,6 +662,7 @@ class Meta: 'name', 'amount', 'donation_count', + 'paypalcurrency', 'hashtag', 'datetime', 'timezone', diff --git a/tracker/migrations/0031_add_euros_to_donation_and_event.py b/tracker/migrations/0031_add_euros_to_donation_and_event.py new file mode 100644 index 000000000..9ae4e5e58 --- /dev/null +++ b/tracker/migrations/0031_add_euros_to_donation_and_event.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.20 on 2023-07-26 10:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0030_add_bid_chain'), + ] + + operations = [ + migrations.AlterField( + model_name='donation', + name='currency', + field=models.CharField(choices=[('USD', 'US Dollars'), ('CAD', 'Canadian Dollars'), ('EUR', 'Euros')], max_length=8, verbose_name='Currency'), + ), + migrations.AlterField( + model_name='event', + name='paypalcurrency', + field=models.CharField(choices=[('USD', 'US Dollars'), ('CAD', 'Canadian Dollars'), ('EUR', 'Euros')], default='USD', max_length=8, verbose_name='Currency'), + ), + ] diff --git a/tracker/migrations/0033_merge_20230814_1327.py b/tracker/migrations/0033_merge_20230814_1327.py new file mode 100644 index 000000000..f0cf4d5e4 --- /dev/null +++ b/tracker/migrations/0033_merge_20230814_1327.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.20 on 2023-08-14 13:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0031_add_euros_to_donation_and_event'), + ('tracker', '0032_add_more_indices'), + ] + + operations = [ + ] diff --git a/tracker/migrations/0039_merge_20240413_0642.py b/tracker/migrations/0039_merge_20240413_0642.py new file mode 100644 index 000000000..42824dfda --- /dev/null +++ b/tracker/migrations/0039_merge_20240413_0642.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.20 on 2024-04-13 06:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0033_merge_20230814_1327'), + ('tracker', '0038_milestone_start_zero'), + ] + + operations = [ + ] diff --git a/tracker/migrations/0040_merge_20240729_1306.py b/tracker/migrations/0040_merge_20240729_1306.py new file mode 100644 index 000000000..ecd74633f --- /dev/null +++ b/tracker/migrations/0040_merge_20240729_1306.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.20 on 2024-07-29 13:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0039_add_incentive_timing'), + ('tracker', '0039_merge_20240413_0642'), + ] + + operations = [ + ] diff --git a/tracker/migrations/0041_merge_20240829_1326.py b/tracker/migrations/0041_merge_20240829_1326.py new file mode 100644 index 000000000..b293ae775 --- /dev/null +++ b/tracker/migrations/0041_merge_20240829_1326.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.20 on 2024-08-29 13:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0040_add_interstitial_anchor'), + ('tracker', '0040_merge_20240729_1306'), + ] + + operations = [ + ] diff --git a/tracker/migrations/0048_merge_20241014_0900.py b/tracker/migrations/0048_merge_20241014_0900.py new file mode 100644 index 000000000..f5bea25be --- /dev/null +++ b/tracker/migrations/0048_merge_20241014_0900.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2024-10-14 09:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0041_merge_20240829_1326'), + ('tracker', '0047_merge_talent_contenttypes'), + ] + + operations = [ + ] diff --git a/tracker/migrations/0049_merge_20241215_1449.py b/tracker/migrations/0049_merge_20241215_1449.py new file mode 100644 index 000000000..fa9286202 --- /dev/null +++ b/tracker/migrations/0049_merge_20241215_1449.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2024-12-15 14:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0048_merge_20241014_0900'), + ('tracker', '0048_remove_old_ad_permission'), + ] + + operations = [ + ] diff --git a/tracker/migrations/0055_merge_20250119_1022.py b/tracker/migrations/0055_merge_20250119_1022.py new file mode 100644 index 000000000..74eb85e58 --- /dev/null +++ b/tracker/migrations/0055_merge_20250119_1022.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2025-01-19 10:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0049_merge_20241215_1449'), + ('tracker', '0054_delete_event_targetamount'), + ] + + operations = [ + ] diff --git a/tracker/models/donation.py b/tracker/models/donation.py index 1f7bd2323..7d8eb4d83 100644 --- a/tracker/models/donation.py +++ b/tracker/models/donation.py @@ -26,7 +26,11 @@ 'Milestone', ] -_currencyChoices = (('USD', 'US Dollars'), ('CAD', 'Canadian Dollars')) +_currencyChoices = ( + ('USD', 'US Dollars'), + ('CAD', 'Canadian Dollars'), + ('EUR', 'Euros'), +) DonorVisibilityChoices = ( ('FULL', 'Fully Visible'), diff --git a/tracker/models/event.py b/tracker/models/event.py index e060475f7..0a0fd1a15 100644 --- a/tracker/models/event.py +++ b/tracker/models/event.py @@ -32,7 +32,11 @@ 'VideoLinkType', ] -_currencyChoices = (('USD', 'US Dollars'), ('CAD', 'Canadian Dollars')) +_currencyChoices = ( + ('USD', 'US Dollars'), + ('CAD', 'Canadian Dollars'), + ('EUR', 'Euros'), +) logger = logging.getLogger(__name__)