Path: blob/main/components/donate/DonateWidget.tsx
1006 views
import classNames from "classnames"1import { useCallback, useState } from "react"2import {3defineMessages,4FormattedMessage,5FormattedNumber,6useIntl,7} from "react-intl"8import { Select } from "@headlessui/react"910import CheckIcon from "../../public/icons/check.svg?inline"11import DropdownArrowIcon from "../../public/icons/dropdown-arrow.svg?inline"12import type {13Currency,14CampaignResponse,15DonationFrequency,16} from "../../types/api"17import { Button } from "../Button"18import { Input } from "../Input"1920export type OnDonateFn = (21amount: number,22frequency: DonationFrequency,23currency: Currency24) => void2526interface DonateWidgetProps {27className?: string28onDonate: OnDonateFn29messages: Pick<CampaignResponse, "donation_message" | "donation_button_text">30defaultCurrency: Currency31amounts: CampaignResponse["amounts"]32defaultFrequency?: DonationFrequency33defaultAmount?: number34}3536const messages = defineMessages({37one_time: {38id: "donate_widget.frequency.once",39defaultMessage: "Just once",40},41monthly: {42id: "donate_widget.frequency.monthly",43defaultMessage: "Monthly",44},45yearly: {46id: "donate_widget.frequency.yearly",47defaultMessage: "Yearly",48},49loadingCheckout: {50id: "donate_widget.loading_checkout",51defaultMessage: "Loading…",52},53currencySelect: {54id: "donate_widget.currency_select",55defaultMessage: "Select currency",56},57amountSelect: {58id: "donate_widget.amount_select",59defaultMessage: "Amount to donate",60},61amountButton: {62id: "donate_widget.amount_button",63defaultMessage: "Select {amount}",64},65amountError: {66id: "donate_widget.amount_error",67defaultMessage: "Please input an amount above 1",68},69loadingError: {70id: "donate_widget.loading_error",71defaultMessage: "Loading checkout timed out, please try again",72},73})7475export function DonateWidget({76className,77messages: { donation_message, donation_button_text },78amounts,79onDonate,80defaultAmount,81defaultCurrency,82defaultFrequency,83}: DonateWidgetProps) {84const frequencies = Object.keys(amounts) as DonationFrequency[]85const [frequency, setFrequency] = useState<DonationFrequency>(86() => defaultFrequency ?? (frequencies.at(-1) as DonationFrequency)87)88const [currency, setCurrency] = useState<Currency>(defaultCurrency)89const [currentAmount, setCurrentAmount] = useState(90() => defaultAmount ?? amounts[frequency][currency][0]91)92const [amountDisplay, setAmountDisplay] = useState(() =>93((defaultAmount ?? amounts[frequency][currency][0]) / 100).toFixed(2)94)95const [dirty, setDirty] = useState(false)96const [loadingCheckout, setLoadingCheckout] = useState(false)97const [error, setError] = useState<string | null>(null)9899const intl = useIntl()100101const updateAmount = useCallback((amount: number) => {102const intAmount = Math.round(amount)103if (isNaN(intAmount) || intAmount < 100) {104return105}106setCurrentAmount(intAmount)107setAmountDisplay((intAmount / 100).toFixed(2))108}, [])109110const handleChangeFrequency = useCallback(111(toFrequency: DonationFrequency) => () => {112setFrequency(toFrequency)113updateAmount(amounts[toFrequency][currency][0])114setDirty(false)115setError(null)116},117[amounts, currency, updateAmount]118)119const handleChangeCurrency = useCallback(120(toCurrency: Currency) => {121setCurrency(toCurrency)122updateAmount(amounts[frequency][toCurrency][0])123setDirty(false)124setError(null)125},126[amounts, frequency, updateAmount]127)128const handleChangeAmount: React.ChangeEventHandler<HTMLInputElement> =129useCallback(130(event) => {131setDirty(true)132const { value, valueAsNumber } = event.currentTarget133setAmountDisplay(134value.replaceAll(/[^0-9\.]+/g, "") ||135valueAsNumber.toFixed(2) ||136(currentAmount / 100).toFixed(2)137)138if (isNaN(valueAsNumber) || valueAsNumber < 1) {139return140}141setCurrentAmount(valueAsNumber * 100)142},143[currentAmount]144)145const handleClickAmount = useCallback(146(amount: number) => {147updateAmount(amount)148setDirty(false)149setError(null)150},151[updateAmount]152)153const handleBlurAmount = useCallback(() => {154setAmountDisplay((currentAmount / 100).toFixed(2))155}, [currentAmount])156157const handleDonate = useCallback(() => {158setLoadingCheckout(true)159onDonate(currentAmount, frequency, currency)160setTimeout(() => {161setLoadingCheckout(false)162setError(intl.formatMessage(messages.loadingError))163}, 5000)164}, [currency, currentAmount, frequency, intl, onDonate])165166return (167<div className={classNames("dark:text-white", className)}>168<p className="sh1">{donation_message}</p>169{error && <p className="text-error text-center text-sm my-4">{error}</p>}170<div className="flex text-center my-4">171{frequencies.map((freq) => (172<Button173key={freq}174className="rounded-none first:rounded-l-md last:rounded-r-md pr-6 group"175dark={freq === frequency}176onClick={handleChangeFrequency(freq)}177disabled={loadingCheckout}178fullWidth179>180<CheckIcon181className={classNames(182"fill-black w-auto h-4 transition-opacity",183frequency !== freq && "opacity-0 group-hover:opacity-100"184)}185/>186{intl.formatMessage(messages[freq])}187</Button>188))}189</div>190191<div className="flex focus-within:shadow-input rounded-md">192<span className="relative">193<DropdownArrowIcon className="absolute left-0 top-[9px] fill-white pointer-events-none" />194<Select195className={classNames(196"h-full p-2 pl-6 rounded-l-md outline-none transition-colors cursor-pointer disabled:cursor-default font-medium",197"text-white bg-blurple-500 hocus:bg-blurple-600",198"disabled:bg-gray-2 disabled:hocus:bg-gray-2"199)}200value={currency}201onChange={(e) => handleChangeCurrency(e.target.value as Currency)}202aria-label={intl.formatMessage(messages.currencySelect)}203disabled={loadingCheckout}204>205<option value="USD">206<FormattedMessage207id="donate_widget.currency.usd"208defaultMessage="USD"209/>210</option>211<option value="EUR">212<FormattedMessage213id="donate_widget.currency.eur"214defaultMessage="EUR"215/>216</option>217</Select>218</span>219<Input220className="rounded-l-none focus:shadow-none dark:bg-black"221type="number"222value={amountDisplay}223onChange={handleChangeAmount}224onBlur={handleBlurAmount}225min={1}226step={0.01}227aria-label={intl.formatMessage(messages.amountSelect)}228disabled={loadingCheckout}229fullWidth230/>231</div>232<div className="flex gap-2 mt-4">233{amounts[frequency][currency].map((amount) => (234<Button235className="transition-none"236key={amount}237onClick={() => handleClickAmount(amount)}238dark={amount === currentAmount && !dirty}239aria-label={intl.formatMessage(messages.amountButton, {240amount: intl.formatNumber(amount / 100, {241style: "currency",242currency,243maximumFractionDigits: 0,244}),245})}246disabled={loadingCheckout}247fullWidth248>249<FormattedNumber250value={amount / 100}251style="currency"252currency={currency}253maximumFractionDigits={0}254/>255</Button>256))}257</div>258259<Button260className="mt-4"261onClick={handleDonate}262dark263disabled={loadingCheckout}264fullWidth265>266{loadingCheckout267? intl.formatMessage(messages.loadingCheckout)268: donation_button_text}269</Button>270</div>271)272}273274275