Skip to content

Commit 751dfbf

Browse files
committed
creates new self-contained popup loader
1 parent 4c96068 commit 751dfbf

File tree

11 files changed

+237
-23
lines changed

11 files changed

+237
-23
lines changed

components/Button.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,40 @@ import {
44
} from "@headlessui/react"
55
import classNames from "classnames"
66

7-
type ButtonProps = HeadlessButtonProps & {
7+
export type ButtonProps = HeadlessButtonProps & {
88
dark?: boolean
9+
size?: "small" | "medium" | "large"
10+
fullWidth?: boolean
11+
borderless?: boolean
912
}
1013

1114
export function Button({
1215
children,
1316
className,
1417
dark = false,
18+
size = "medium",
19+
fullWidth = false,
20+
borderless = false,
1521
...props
1622
}: React.PropsWithChildren<ButtonProps>) {
1723
return (
1824
<HeadlessButton
1925
{...props}
2026
className={classNames(
2127
className,
22-
"w-full p-2 flex gap-2 items-center justify-center rounded-md",
23-
"text-center font-semibold transition-colors focus:outline-none border-2 disabled:cursor-default",
24-
"disabled:bg-gray-2 disabled:hocus:bg-gray-2 disabled:border-gray-2 disabled:hocus:border-gray-2 disabled:text-white",
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",
2535
!dark &&
26-
"bg-white dark:bg-black hocus:bg-blurple-600 border-blurple-500 hocus:border-blurple-600 text-blurple-500 hocus:text-white",
27-
dark &&
28-
"bg-blurple-500 hocus:bg-blurple-600 border-[transparent] text-white"
36+
"bg-white dark:bg-black hocus:bg-blurple-600 text-blurple-500 hocus:text-white",
37+
!borderless &&
38+
!dark &&
39+
"border-2 border-blurple-500 hocus:border-blurple-600 disabled:border-gray-2 disabled:hocus:border-gray-2",
40+
dark && "bg-blurple-500 hocus:bg-blurple-600 text-white"
2941
)}
3042
>
3143
{children}

components/DonateWidget.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useCallback, useState } from "react"
33
import { defineMessages, useIntl } from "react-intl"
44
import { Input, Select } from "@headlessui/react"
55

6+
import { sendMessage } from "../donate/utils"
67
import CheckIcon from "../public/icons/check.svg?inline"
78
import { useCurrencyFormatter } from "../utils/use-currency-formatter"
89
import { Button } from "./Button"
@@ -96,6 +97,7 @@ export function DonateWidget({
9697
const handleDonate = useCallback(() => {
9798
setLoadingCheckout(true)
9899
onDonate(currentAmount, frequency, currency)
100+
sendMessage("checkout-start")
99101
}, [currency, currentAmount, frequency, onDonate])
100102

101103
return (
@@ -111,6 +113,7 @@ export function DonateWidget({
111113
dark={freq === frequency}
112114
onClick={handleChangeFrequency(freq)}
113115
disabled={loadingCheckout}
116+
fullWidth
114117
>
115118
<CheckIcon className="fill-black w-auto h-4" />
116119
{intl.formatMessage(messages[freq])}
@@ -157,6 +160,7 @@ export function DonateWidget({
157160
dark={amount === currentAmount && !dirty}
158161
aria-label={`Select ${formatter.format(amount / 100)}`}
159162
disabled={loadingCheckout}
163+
fullWidth
160164
>
161165
{formatter.format(amount / 100)}
162166
</Button>
@@ -168,6 +172,7 @@ export function DonateWidget({
168172
onClick={handleDonate}
169173
dark
170174
disabled={loadingCheckout}
175+
fullWidth
171176
>
172177
{loadingCheckout
173178
? intl.formatMessage(messages.loadingCheckout)

donate/DonatePopup.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
PropsWithChildren,
3+
useCallback,
4+
useEffect,
5+
useRef,
6+
useState,
7+
} from "react"
8+
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react"
9+
import classNames from "classnames"
10+
11+
import { isPopupMessage, isPopupResizeMessage } from "./utils"
12+
import { Button, ButtonProps } from "../components/Button"
13+
14+
import type { Step } from "./types"
15+
16+
export function DonatePopup({
17+
children,
18+
...buttonProps
19+
}: PropsWithChildren<ButtonProps>) {
20+
const iframeRef = useRef<HTMLIFrameElement>(null)
21+
22+
const [open, setOpen] = useState(false)
23+
const [currentStep, setCurrentStep] = useState<Step>("loading")
24+
25+
const handleOpen = useCallback(() => {
26+
setOpen(true)
27+
setCurrentStep("loading")
28+
}, [])
29+
const handleClose = () => {
30+
setOpen(false)
31+
setTimeout(() => setCurrentStep("loading"), 300)
32+
}
33+
34+
useEffect(() => {
35+
const handleMessage = (event: MessageEvent) => {
36+
const message = event.data
37+
if (!isPopupMessage(message)) {
38+
return
39+
}
40+
41+
if (isPopupResizeMessage(message)) {
42+
iframeRef.current?.style.setProperty("height", `${message.height}px`)
43+
return
44+
}
45+
switch (message.action) {
46+
case "checkout-loaded": {
47+
setCurrentStep("checkout")
48+
break
49+
}
50+
case "checkout-complete": {
51+
handleClose()
52+
break
53+
}
54+
}
55+
}
56+
window.addEventListener("message", handleMessage)
57+
return () => {
58+
window.removeEventListener("message", handleMessage)
59+
}
60+
}, [])
61+
62+
const handleIframeLoad = () => {
63+
setCurrentStep("choose")
64+
}
65+
66+
return (
67+
<>
68+
<Button {...buttonProps} onClick={handleOpen}>
69+
{children}
70+
</Button>
71+
<Dialog
72+
open={open}
73+
onClose={handleClose}
74+
transition
75+
className="transition-opacity opacity-0 data-[open]:opacity-100"
76+
>
77+
<DialogBackdrop className="fixed inset-0 bg-black/30" />
78+
<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
79+
<DialogPanel
80+
className={classNames(
81+
"w-full bg-white rounded-md overflow-hidden flex items-center justify-center transition-all relative",
82+
currentStep === "loading" && "p-4 min-h-40 max-w-md",
83+
currentStep === "choose" && "max-w-md",
84+
currentStep === "checkout" && "max-w-5xl"
85+
)}
86+
>
87+
<iframe
88+
src="/donate"
89+
className={classNames(
90+
"w-full transition-all",
91+
currentStep === "loading" && "hidden"
92+
)}
93+
ref={iframeRef}
94+
onLoad={handleIframeLoad}
95+
></iframe>
96+
{currentStep === "loading" && (
97+
<p className="text-center">Loading...</p>
98+
)}
99+
<button
100+
className={classNames(
101+
"absolute top-2 right-2",
102+
currentStep === "checkout" && "text-white"
103+
)}
104+
onClick={handleClose}
105+
>
106+
x
107+
</button>
108+
</DialogPanel>
109+
</div>
110+
</Dialog>
111+
</>
112+
)
113+
}

donate/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
interface MessageDataBase {
2+
source: "donate-widget"
3+
}
4+
export interface MessageData extends MessageDataBase {
5+
action: string
6+
}
7+
export interface MessageDataResize extends MessageDataBase {
8+
action: "checkout-resize"
9+
height: number
10+
}
11+
export type Step = "loading" | "choose" | "checkout" | "complete"

donate/utils.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useEffect } from "react"
2+
3+
import type { MessageData, MessageDataResize } from "./types"
4+
5+
export function sendMessage(action: string) {
6+
if (window.parent) {
7+
window.parent.postMessage({
8+
source: "donate-widget",
9+
action,
10+
} satisfies MessageData)
11+
}
12+
}
13+
export function isPopupMessage(data: unknown): data is MessageData {
14+
return (
15+
data &&
16+
typeof data === "object" &&
17+
"source" in data &&
18+
data.source === "donate-widget" &&
19+
"action" in data &&
20+
typeof data.action === "string"
21+
)
22+
}
23+
24+
export function isPopupResizeMessage(data: unknown): data is MessageDataResize {
25+
return (
26+
isPopupMessage(data) && "height" in data && typeof data.height === "number"
27+
)
28+
}
29+
30+
export function usePopupSizer() {
31+
useEffect(() => {
32+
const observer = new ResizeObserver((entries) => {
33+
for (const entry of entries) {
34+
const { height } = entry.contentRect
35+
if (height > 0 && window.parent) {
36+
window.parent.postMessage({
37+
source: "donate-widget",
38+
action: "checkout-resize",
39+
height,
40+
} satisfies MessageDataResize)
41+
}
42+
}
43+
})
44+
observer.observe(window.document.body)
45+
46+
return () => {
47+
observer.disconnect()
48+
}
49+
}, [])
50+
}

next.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const nextConfig: NextConfig = {
5858
key: "Content-Security-Policy",
5959
value: cspMapToString({
6060
"default-src": ["self"],
61-
"child-src": ["none"],
61+
"child-src": ["self"],
6262
"object-src": ["none"],
6363
"img-src": ["self", "proxy.joinmastodon.org", "blob:", "data:"],
6464
"style-src": ["self", "unsafe-inline"],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@types/lodash": "^4.17.16",
5050
"@types/node": "^22.0.0",
5151
"@types/react": "18",
52+
"@types/react-dom": "^19.1.2",
5253
"@typescript-eslint/eslint-plugin": "^8.0.0",
5354
"@typescript-eslint/parser": "^8.29.1",
5455
"autoprefixer": "^10.4.20",

pages/donate/checkout.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import {
55
} from "@stripe/react-stripe-js"
66
import { z } from "zod"
77
import { loadStripe } from "@stripe/stripe-js"
8-
import { useMemo } from "react"
8+
import { useEffect, useMemo } from "react"
99

10+
import { sendMessage, usePopupSizer } from "../../donate/utils"
1011
import { CURRENCIES, DONATION_FREQUENCIES } from "../../types/api"
1112

1213
export default function DonateCheckoutPage({
@@ -17,15 +18,22 @@ export default function DonateCheckoutPage({
1718
() => loadStripe(stripePublicKey),
1819
[stripePublicKey]
1920
)
21+
22+
useEffect(() => {
23+
sendMessage("checkout-loaded")
24+
}, [])
25+
usePopupSizer()
26+
2027
return (
21-
<div>
22-
<EmbeddedCheckoutProvider
23-
stripe={loadStripePromise}
24-
options={{ clientSecret }}
25-
>
26-
<EmbeddedCheckout />
27-
</EmbeddedCheckoutProvider>
28-
</div>
28+
<EmbeddedCheckoutProvider
29+
stripe={loadStripePromise}
30+
options={{
31+
clientSecret,
32+
onComplete: () => sendMessage("checkout-complete"),
33+
}}
34+
>
35+
<EmbeddedCheckout />
36+
</EmbeddedCheckoutProvider>
2937
)
3038
}
3139

pages/donate/index.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { useRouter } from "next/navigation"
55
import { useCallback } from "react"
66
import { z } from "zod"
77

8-
import { fetchEndpoint } from "../../utils/api"
9-
import { CampaignResponse } from "../../types/api"
8+
import { usePopupSizer } from "../../donate/utils"
109
import { OnDonateFn, DonateWidget } from "../../components/DonateWidget"
10+
import { CampaignResponse } from "../../types/api"
11+
import { fetchEndpoint } from "../../utils/api"
1112

1213
export default function DonatePage({
1314
theme,
@@ -31,6 +32,8 @@ export default function DonatePage({
3132
[donation_url, router]
3233
)
3334

35+
usePopupSizer()
36+
3437
return (
3538
<div className={classNames(theme, "bg-white dark:bg-black min-h-screen")}>
3639
<DonateWidget

pages/sponsors.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
11
import Head from "next/head"
22
import Image from "next/legacy/image"
33
import classNames from "classnames"
4+
import Link from "next/link"
45
import { FormattedMessage, useIntl } from "react-intl"
6+
57
import Hero from "../components/Hero"
68
import SponsorCard from "../components/SponsorCard"
79
import SponsorLogoGroup from "../components/SponsorLogoGroup"
810
import { withDefaultStaticProps } from "../utils/defaultStaticProps"
911
import sponsors from "../data/sponsors"
1012
import sponsorData from "../data/sponsors"
13+
import { DonatePopup } from "../donate/DonatePopup"
1114
import Layout from "../components/Layout"
1215
import LinkButton from "../components/LinkButton"
1316

1417
import MastodonInTheCloudsIllustration from "../public/illustrations/mastodon_in_the_clouds.png"
1518
import MastodonWithLaptopIllustration from "../public/illustrations/mastodon_with_laptop.png"
1619
import MasotodonFediverseIllustration from "../public/illustrations/mastodon_fediverse.png"
1720
import MastodonsCheeringIllustration from "../public/illustrations/mastodons_cheering.png"
18-
1921
import previewImage from "../public/sponsors_preview.png"
2022
import usFlagIcon from "../public/united_states_flag_icon_round.svg"
21-
import Link from "next/link"
2223

2324
interface DonateCardProps {
2425
title: React.ReactNode
@@ -83,12 +84,12 @@ function Sponsors() {
8384
/>
8485
</p>
8586
<div className="flex gap-6">
86-
<LinkButton size="large" href="#donate">
87+
<DonatePopup size="large" dark>
8788
<FormattedMessage
8889
id="sponsors.hero.cta.donate"
8990
defaultMessage="Donate"
9091
/>
91-
</LinkButton>
92+
</DonatePopup>
9293
<LinkButton size="large" light borderless href="#supported_by">
9394
<FormattedMessage
9495
id="sponsors.hero.cta.view_sponsors"

0 commit comments

Comments
 (0)