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