Skip to content

Commit d79cb72

Browse files
authored
Merge pull request #1003 from mastodon/feat/donation-standalone-page
Make donation widget work as a standalone page
2 parents 0c9ecaf + 08c01f6 commit d79cb72

File tree

14 files changed

+364
-159
lines changed

14 files changed

+364
-159
lines changed

.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
root = true
12
tab_width = 2
23
indent_size = 2
34
indent_style = space

components/Hero.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import { useRouter } from "next/router"
22
import Image, { StaticImageData } from "next/legacy/image"
33
import { CSSProperties } from "react"
4-
import { getDirForLocale } from "../utils/locales"
4+
import classNames from "classnames"
55

66
import defaultMobileImage from "../public/illustrations/default_hero_mobile.png"
77
import defaultDesktopImage from "../public/illustrations/default_hero_desktop.png"
8-
import classNames from "classnames"
8+
import { getDirForLocale } from "../utils/locales"
99

1010
export type HeroProps = {
1111
/** Static import of mobile image */
1212
mobileImage?: StaticImageData
1313
/** Static import of desktop image */
1414
desktopImage?: StaticImageData
1515
/** Text content */
16-
children: React.ReactNode
16+
children?: React.ReactNode
1717
/** Large, centered hero style used on the homepage */
1818
homepage?: boolean
1919
/** Adds a text shadow to the hero's content */

components/donate/DonateCheckout.tsx

Lines changed: 114 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { PaymentElement, useCheckout } from "@stripe/react-stripe-js"
22
import classNames from "classnames"
33
import Link from "next/link"
4-
import { ChangeEvent, FormEvent, useCallback, useState } from "react"
4+
import { ChangeEvent, FormEvent, useCallback, useEffect, useState } from "react"
55
import { FormattedMessage } from "react-intl"
66

77
import LoadingIcon from "../../public/icons/loading.svg?inline"
88
import ArrowLeftIcon from "../../public/ui/arrow-left.svg?inline"
9+
import ExternalLinkIcon from "../../public/ui/external-link.svg?inline"
910

1011
import { Button } from "../Button"
1112
import { Input } from "../Input"
13+
import { isInIframe } from "../../donate/utils"
1214

1315
interface DonateCheckoutProps {
1416
backUrl?: string
@@ -72,47 +74,66 @@ export function DonateCheckout({
7274
[checkout, email, onComplete]
7375
)
7476

77+
// Determine if we are in an external iframe in client-side.
78+
const [isExternal, setIsExternal] = useState(false)
79+
useEffect(() => {
80+
setIsExternal(
81+
isInIframe() && window.parent.location.host !== window.location.host
82+
)
83+
}, [])
84+
7585
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-
/>
86+
<form className={className} onSubmit={handleCheckout}>
87+
<header className="mb-4 flex flex-col gap-2">
88+
<div className="border dark:border-gray-1 p-4 rounded-md">
89+
<h3 className="text-b1">
90+
{checkout.recurring ? (
91+
<FormattedMessage
92+
id="donate_widget.checkout.header.recurring"
93+
defaultMessage="You are donating {total} every {frequency}"
94+
values={{
95+
total: checkout.total.total.amount,
96+
frequency: checkout.recurring.interval,
97+
}}
98+
/>
99+
) : (
100+
<FormattedMessage
101+
id="donate_widget.checkout.header.one_time"
102+
defaultMessage="You are donating {total} once"
103+
values={{
104+
total: checkout.total.total.amount,
105+
}}
106+
/>
107+
)}
108+
</h3>
109+
{checkout.recurring && (
110+
<p className="text-b4 mt-2 text-gray-1 dark:text-gray-2">
111+
<FormattedMessage
112+
id="donate_widget.checkout.header.recurring_info"
113+
defaultMessage="You will be charged {total} today and every following {frequency}. You can cancel your recurring donation at any time."
114+
values={{
115+
total: checkout.total.total.amount,
116+
frequency: checkout.recurring.interval,
117+
}}
118+
/>
119+
</p>
99120
)}
100-
</h3>
121+
</div>
101122
{backUrl && (
102123
<Link
103124
href={backUrl}
104-
className="text-gray-1 text-b3 mt-2 flex gap-1 items-center"
125+
className="text-gray-1 dark:text-gray-2 text-b3 mt-2 flex gap-1 items-center -order-1"
105126
>
106-
<ArrowLeftIcon className="size-4" />
127+
<ArrowLeftIcon className="size-4 fill-current" />
107128
<FormattedMessage
108129
id="donate_widget.checkout.header.back"
109130
defaultMessage="Edit your donation"
110131
/>
111132
</Link>
112133
)}
113134
</header>
114-
<hr className="my-4 border-t border-gray-2" />
115-
<div className="flex max-sm:flex-col gap-4">
135+
136+
<div className="flex flex-col gap-4 mb-4">
116137
<label className="w-full">
117138
<FormattedMessage
118139
id="donate_widget.checkout.email"
@@ -142,39 +163,71 @@ export function DonateCheckout({
142163
</div>
143164
</div>
144165

145-
<div className="mt-4">
146-
{errorMessage && (
147-
<p className="text-error text-b3 mb-2">{errorMessage}</p>
166+
{errorMessage && (
167+
<p className="text-error text-b3 mb-2">{errorMessage}</p>
168+
)}
169+
<Button
170+
disabled={isLoading}
171+
dark
172+
className={classNames(
173+
"flex gap-2 items-center justify-center",
174+
isLoading && "text-gray-2"
148175
)}
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>
176+
fullWidth
177+
type="submit"
178+
>
179+
<DonateCheckoutButtonText isLoading={isLoading} />
180+
{isExternal && <ExternalLinkIcon className="fill-current" />}
181+
</Button>
182+
{isExternal && (
183+
<p className="text-b4 text-center mt-2 text-gray-1 dark:text-gray-2">
184+
<FormattedMessage
185+
id="donate_widget.checkout.external_notice"
186+
defaultMessage="You will be redirected to {url} for secure payment."
187+
values={{
188+
url: typeof window !== "undefined" ? window.location.host : "",
189+
}}
190+
/>
191+
</p>
192+
)}
178193
</form>
179194
)
180195
}
196+
197+
function DonateCheckoutButtonText({ isLoading }: { isLoading: boolean }) {
198+
const checkout = useCheckout()
199+
if (isLoading) {
200+
return (
201+
<>
202+
<LoadingIcon className="motion-safe:animate-spin size-5" />
203+
<FormattedMessage
204+
id="donate_widget.checkout.submitting"
205+
defaultMessage="Submitting…"
206+
/>
207+
</>
208+
)
209+
}
210+
211+
if (checkout.recurring) {
212+
return (
213+
<FormattedMessage
214+
id="donate_widget.checkout.pay_button"
215+
defaultMessage="Subscribe for {total} every {frequency}"
216+
values={{
217+
total: checkout.total.total.amount,
218+
frequency: checkout.recurring.interval,
219+
}}
220+
/>
221+
)
222+
}
223+
224+
return (
225+
<FormattedMessage
226+
id="donate_widget.checkout.pay_button"
227+
defaultMessage="Pay {total} now"
228+
values={{
229+
total: checkout.total.total.amount,
230+
}}
231+
/>
232+
)
233+
}

components/donate/DonateFooter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import LogoPurple from "../../public/logos/logo-purple.svg?inline"
44

55
export function DonateFooter() {
66
return (
7-
<footer className="flex gap-2 items-center mt-3 text-b4 bg-gray-3 px-8 py-2">
7+
<footer className="flex gap-2 items-center mt-3 text-b4 bg-gray-3 dark:bg-black dark:text-gray-2 px-8 py-2">
88
<LogoPurple className="size-6" />
99
<FormattedMessage
1010
id="donate_widget.footer"

components/donate/DonateWidget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ export function DonateWidget({
218218
</Select>
219219
</span>
220220
<Input
221-
className="rounded-l-none focus:shadow-none"
221+
className="rounded-l-none focus:shadow-none dark:bg-black"
222222
type="number"
223223
value={amountDisplay}
224224
onChange={handleChangeAmount}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import classNames from "classnames"
2+
import Head from "next/head"
3+
import Image from "next/image"
4+
import { useLayoutEffect, useState } from "react"
5+
6+
import { Theme } from "../../donate/types"
7+
import { isInIframe } from "../../donate/utils"
8+
9+
import heroDesktopImage from "../../public/illustrations/default_hero_desktop.png"
10+
import heroMobileImage from "../../public/illustrations/default_hero_mobile.png"
11+
12+
interface DonateWrapperProps {
13+
children: React.ReactNode
14+
className?: string
15+
theme: Theme
16+
belowModal?: React.ReactNode
17+
}
18+
19+
export function DonateWrapper({
20+
children,
21+
className,
22+
theme = "auto",
23+
belowModal,
24+
}: DonateWrapperProps) {
25+
// This is done this way to avoid hydration errors.
26+
// See: https://nextjs.org/docs/messages/react-hydration-error
27+
const [isClient, setIsClient] = useState(false)
28+
useLayoutEffect(() => {
29+
setIsClient(isInIframe())
30+
}, [])
31+
32+
if (isClient) {
33+
return (
34+
<div
35+
className={classNames(
36+
"bg-white dark:bg-black min-h-screen flex flex-col",
37+
theme,
38+
className
39+
)}
40+
>
41+
{children}
42+
</div>
43+
)
44+
}
45+
46+
return (
47+
<main
48+
className={classNames(
49+
theme,
50+
"min-h-screen dark:bg-black overflow-hidden relative px-4"
51+
)}
52+
>
53+
<div
54+
className={classNames(
55+
"w-full max-w-md mx-auto mt-20 flex flex-col relative z-10",
56+
"bg-white dark:bg-black text-black dark:text-white dark:border dark:border-gray-0 rounded-lg overflow-hidden drop-shadow-lg",
57+
className
58+
)}
59+
>
60+
{children}
61+
</div>
62+
{belowModal}
63+
64+
<div className="fixed top-0 left-0">
65+
<Image
66+
src={heroDesktopImage}
67+
alt=""
68+
role="presentation"
69+
className="hidden xl:block"
70+
objectFit="cover"
71+
objectPosition="center bottom"
72+
placeholder="empty"
73+
/>
74+
<Image
75+
src={heroMobileImage}
76+
alt=""
77+
role="presentation"
78+
className="block xl:hidden"
79+
objectFit="cover"
80+
objectPosition="center bottom"
81+
placeholder="empty"
82+
/>
83+
</div>
84+
85+
<Head>
86+
<title>Donate - Mastodon</title>
87+
</Head>
88+
</main>
89+
)
90+
}

donate/DonatePopup.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ export function DonatePopup({
3030
setOpen(true)
3131
setCurrentStep("loading")
3232
}, [])
33-
const handleClose = () => {
33+
const handleClose = useCallback(() => {
3434
setOpen(false)
3535
setTimeout(() => setCurrentStep("loading"), 300)
36-
}
36+
}, [])
3737

3838
useEffect(() => {
3939
const handleMessage = (event: MessageEvent) => {
@@ -61,7 +61,7 @@ export function DonatePopup({
6161
return () => {
6262
window.removeEventListener("message", handleMessage)
6363
}
64-
}, [])
64+
}, [handleClose])
6565

6666
return (
6767
<>

donate/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { z } from "zod"
22
import type { MessageData } from "./types"
33

4+
export function isInIframe() {
5+
return typeof window !== "undefined" && window.self !== window.top
6+
}
7+
48
export function sendMessage(action: string) {
5-
if (window.parent) {
9+
if (isInIframe()) {
610
window.parent.postMessage({
711
source: "donate-widget",
812
action,
913
} satisfies MessageData)
1014
}
1115
}
16+
1217
export function isPopupMessage(data: unknown): data is MessageData {
1318
return (
1419
data &&

0 commit comments

Comments
 (0)