Skip to content

Commit 2f7a1d9

Browse files
authored
Merge pull request #887 from mastodon/MAS-373-donation-widget
Adds donation widget
2 parents 1380bf1 + ec88059 commit 2f7a1d9

31 files changed

+1392
-55
lines changed

components/Button.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
Button as HeadlessButton,
3+
ButtonProps as HeadlessButtonProps,
4+
} from "@headlessui/react"
5+
import classNames from "classnames"
6+
7+
export type ButtonProps = HeadlessButtonProps & {
8+
dark?: boolean
9+
size?: "small" | "medium" | "large"
10+
fullWidth?: boolean
11+
borderless?: boolean
12+
}
13+
14+
export function Button({
15+
children,
16+
className,
17+
dark = false,
18+
size = "medium",
19+
fullWidth = false,
20+
borderless = false,
21+
...props
22+
}: React.PropsWithChildren<ButtonProps>) {
23+
return (
24+
<HeadlessButton
25+
{...props}
26+
className={classNames(
27+
className,
28+
"p-2 flex gap-2 items-center justify-center rounded-md",
29+
fullWidth ? "w-full" : "w-max",
30+
size === "small" && "b3 h-10",
31+
size === "medium" && "b3 h-12",
32+
size === "large" && "b3 md:b1 h-12 md:h-16 md:px-6",
33+
"text-center font-semibold transition-colors focus:outline-none disabled:cursor-default",
34+
"disabled:bg-gray-2 disabled:hocus:bg-gray-2 disabled:text-white",
35+
!dark &&
36+
"bg-white dark:bg-black hocus:bg-blurple-600 text-blurple-500 hocus:text-white",
37+
dark && "bg-blurple-500 hocus:bg-blurple-600 text-white",
38+
!borderless &&
39+
"border-2 border-blurple-500 hocus:border-blurple-600 disabled:border-gray-2 disabled:hocus:border-gray-2"
40+
)}
41+
>
42+
{children}
43+
</HeadlessButton>
44+
)
45+
}

components/Input.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
Input as HeadlessInput,
3+
InputProps as HeadlessInputProps,
4+
} from "@headlessui/react"
5+
import classNames from "classnames"
6+
7+
export type InputProps = HeadlessInputProps & {
8+
fullWidth?: boolean
9+
}
10+
11+
export function Input({
12+
className,
13+
disabled,
14+
fullWidth = false,
15+
...props
16+
}: InputProps) {
17+
return (
18+
<HeadlessInput
19+
className={classNames(
20+
className,
21+
fullWidth && "w-full",
22+
"px-4 py-2 rounded-md outline-none transition-all",
23+
"border border-blurple-500 focus:shadow-input",
24+
"placeholder:text-gray-2",
25+
"disabled:border-gray-2"
26+
)}
27+
disabled={disabled}
28+
{...props}
29+
/>
30+
)
31+
}

components/TestimonialCard.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import Link from "next/link"
21
import Image from "next/legacy/image"
32
import type { Testimonial } from "../data/testimonials"
43

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { PaymentElement, useCheckout } from "@stripe/react-stripe-js"
2+
import classNames from "classnames"
3+
import Link from "next/link"
4+
import { ChangeEvent, FormEvent, useCallback, useState } from "react"
5+
import { FormattedMessage } from "react-intl"
6+
7+
import LoadingIcon from "../../public/icons/loading.svg?inline"
8+
import ArrowLeftIcon from "../../public/ui/arrow-left.svg?inline"
9+
10+
import { Button } from "../Button"
11+
import { Input } from "../Input"
12+
13+
interface DonateCheckoutProps {
14+
backUrl?: string
15+
className?: string
16+
onComplete: () => void
17+
}
18+
19+
export function DonateCheckout({
20+
className,
21+
backUrl,
22+
onComplete,
23+
}: DonateCheckoutProps) {
24+
const checkout = useCheckout()
25+
26+
const [email, setEmail] = useState("")
27+
const [errorMessage, setErrorMessage] = useState<string | null>(null)
28+
const [isLoading, setIsLoading] = useState(false)
29+
30+
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
31+
setErrorMessage(null)
32+
setEmail(e.target.value)
33+
}, [])
34+
35+
const handleEmailBlur = useCallback(async () => {
36+
if (!email) {
37+
return
38+
}
39+
40+
const result = await checkout.updateEmail(email)
41+
if (result.type === "error") {
42+
setErrorMessage(result.error.message)
43+
} else {
44+
setErrorMessage(null)
45+
}
46+
}, [checkout, email])
47+
48+
const handleCheckout = useCallback(
49+
async (e: FormEvent<HTMLFormElement>) => {
50+
e.preventDefault()
51+
52+
setIsLoading(true)
53+
54+
const result = await checkout.updateEmail(email)
55+
if (result.type === "error") {
56+
setErrorMessage(result.error.message)
57+
setIsLoading(false)
58+
return
59+
}
60+
61+
const confirmResult = await checkout.confirm({
62+
redirect: "if_required", // Only redirect if required
63+
})
64+
65+
if (confirmResult.type === "error") {
66+
setErrorMessage(confirmResult.error.message)
67+
setIsLoading(false)
68+
} else {
69+
onComplete()
70+
}
71+
},
72+
[checkout, email, onComplete]
73+
)
74+
75+
return (
76+
<form
77+
className={classNames("dark:text-white", className)}
78+
onSubmit={handleCheckout}
79+
>
80+
<header className="mb-4">
81+
<h3 className="text-b1">
82+
{checkout.recurring ? (
83+
<FormattedMessage
84+
id="donate_widget.checkout.header.recurring"
85+
defaultMessage="You are donating {total} every {frequency}"
86+
values={{
87+
total: checkout.total.total.amount,
88+
frequency: checkout.recurring.interval,
89+
}}
90+
/>
91+
) : (
92+
<FormattedMessage
93+
id="donate_widget.checkout.header.one_time"
94+
defaultMessage="You are donating {total} once"
95+
values={{
96+
total: checkout.total.total.amount,
97+
}}
98+
/>
99+
)}
100+
</h3>
101+
{backUrl && (
102+
<Link
103+
href={backUrl}
104+
className="text-gray-1 text-b3 mt-2 flex gap-1 items-center"
105+
>
106+
<ArrowLeftIcon className="size-4" />
107+
<FormattedMessage
108+
id="donate_widget.checkout.header.back"
109+
defaultMessage="Edit your donation"
110+
/>
111+
</Link>
112+
)}
113+
</header>
114+
<hr className="my-4 border-t border-gray-2" />
115+
<div className="flex max-sm:flex-col gap-4">
116+
<label className="w-full">
117+
<FormattedMessage
118+
id="donate_widget.checkout.email"
119+
defaultMessage="Email"
120+
>
121+
{(text) => <p className="mb-2">{text}</p>}
122+
</FormattedMessage>
123+
<Input
124+
type="email"
125+
value={email}
126+
onChange={handleChange}
127+
placeholder="[email protected]"
128+
onBlur={handleEmailBlur}
129+
disabled={isLoading}
130+
fullWidth
131+
/>
132+
</label>
133+
134+
<div className="w-full">
135+
<h4 className="mb-2">
136+
<FormattedMessage
137+
id="donate_widget.checkout.payment"
138+
defaultMessage="Payment"
139+
/>
140+
</h4>
141+
<PaymentElement className="" />
142+
</div>
143+
</div>
144+
145+
<div className="mt-4">
146+
{errorMessage && (
147+
<p className="text-error text-b3 mb-2">{errorMessage}</p>
148+
)}
149+
<Button
150+
disabled={isLoading}
151+
dark
152+
className={classNames(
153+
"flex gap-2 items-center justify-center",
154+
isLoading && "text-gray-2"
155+
)}
156+
fullWidth
157+
type="submit"
158+
>
159+
{isLoading ? (
160+
<>
161+
<LoadingIcon className="motion-safe:animate-spin size-5" />
162+
<FormattedMessage
163+
id="donate_widget.checkout.submitting"
164+
defaultMessage="Submitting…"
165+
/>
166+
</>
167+
) : (
168+
<FormattedMessage
169+
id="donate_widget.checkout.pay_button"
170+
defaultMessage="Pay {total} now"
171+
values={{
172+
total: checkout.total.total.amount,
173+
}}
174+
/>
175+
)}
176+
</Button>
177+
</div>
178+
</form>
179+
)
180+
}

components/donate/DonateFooter.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { FormattedMessage } from "react-intl"
2+
3+
import LogoPurple from "../../public/logos/logo-purple.svg?inline"
4+
5+
export function DonateFooter() {
6+
return (
7+
<footer className="flex gap-2 items-center mt-3 text-b4 bg-gray-3 px-8 py-2">
8+
<LogoPurple className="size-6" />
9+
<FormattedMessage
10+
id="donate_widget.footer"
11+
defaultMessage="Donations go to Mastodon gGmbH"
12+
/>
13+
</footer>
14+
)
15+
}

0 commit comments

Comments
 (0)