import { LitElement, html, css, svg, nothing, PropertyValues } from "lit"; import { customElement, state, query, property } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; interface Settings { projects?: Project[]; } interface Project { id: string; name: string; } type PaymentMethod = "coinbase" | "solana"; @customElement("change-donation-form") export class ChangeDonationForm extends LitElement { @property({ type: String, attribute: "nonprofit-id" }) nonprofitId?: string; @property({ type: String, attribute: "public-key" }) publicKey?: string; @property({ type: String, attribute: "success-url" }) successUrl?: string; @property({ type: String, attribute: "title-text" }) title: string = "Make a donation"; @property({ type: Array }) projects: string[] = []; @property({ type: Array, attribute: "quick-amounts" }) quickAmounts: number[] = [10, 20, 50]; @property({ type: Boolean }) solana?: boolean = false; @state() selectedQuickAmount?: number; @state() formValid = false; @state() state: "form" | "loading" | "paying" | "paid" = "form"; // Whichever payment method is current loading. This only affects the visual state of the // payment method buttons. @state() loadingPaymentMethod?: PaymentMethod; @state() solanaAddress?: string; // The external ID that identifies the coinbase checkout. // Undefined until a coinbase checkout is generated. @state() coinbaseCheckoutExternalId?: string; @query("input[name=custom-amount]") customAmountInput!: HTMLInputElement; @query("input[name=first-name]") firstNameInput!: HTMLInputElement; @query("input[name=last-name]") lastNameInput!: HTMLInputElement; @query("select[name=project]") projectSelect!: HTMLSelectElement; VERSION = "2.0.0"; @state() settings: Settings | undefined = undefined; render() { return html`
this.handleFormInput()}>

${this.title}

${this.quickAmounts.map( (amount, i) => html` ` )}
this.handleCustomAmountInput()} name="custom-amount" type="number" /> $
this.checkFormValidity()} name="first-name" required minlength="1" /> this.checkFormValidity()} name="last-name" required minlength="1" /> ${this.settings !== undefined && (this.settings.projects?.length || 0) > 0 ? html` ` : nothing} ${this.renderCheckoutLinks()}
powered by
`; } firstUpdated() { this.fetchSettings(); } private async fetchSettings() { this.settings = await fetch( `https://api.getchange.io/api/v1/drop_in/donation_form/settings?component_version=${this.VERSION}&nonprofit_id=${this.nonprofitId}` ) .then((response) => response.json() as Settings) .catch(() => ({})); } private renderCheckoutLinks() { if (this.state == "paying") { return html`Pay in new tab`; } if (this.state == "paid") { return html`Thank you for donating!`; } return html`
${this.solana ? html` this.handleSolanaClick()} target="_blank" > ${this.loadingPaymentMethod === "solana" ? loadingImage() : "Continue with Solana"} ` : nothing} this.handleCoinbaseClick()} target="_blank" > ${this.loadingPaymentMethod === "coinbase" ? loadingImage() : "Continue with Coinbase"}
`; } private checkoutLinksDisabled() { return !this.formValid || this.state !== "form"; } private get amount() { if (this.selectedQuickAmount !== undefined) { return this.quickAmounts[this.selectedQuickAmount]; } if (!this.customAmountInput) { return 0; } return Number(this.customAmountInput.value); } private get coinbaseCheckoutLink() { if (!this.formValid) { return undefined; } const params = new URLSearchParams(); params.set("amount", (this.amount * 100.0).toString()); params.set("nonprofit_id", this.nonprofitId!); params.set("metadata[first_name]", this.firstNameInput?.value); params.set("metadata[last_name]", this.lastNameInput?.value); params.set("project_id", this.selectedProjectId()); this.getAttributeNames().forEach((name) => { if (name.startsWith("metadata-")) { const key = name.substring("metadata-".length); params.set(`metadata[${key}]`, this.getAttribute(name) as string); } }); // Make up a unique ID for the checkout. This ID is used to poll for the // payment status. this.coinbaseCheckoutExternalId = Math.random().toString().slice(2); params.set("external_id", this.coinbaseCheckoutExternalId); if (this.publicKey !== undefined) { params.set("public_key", this.publicKey); } return `https://api.getchange.io/api/v1/payments/crypto_checkout_link?${params.toString()}`; } private handleCoinbaseClick() { if (this.checkoutLinksDisabled()) { return; } this.state = "paying"; // Poll https://api.getchange.io/api/v1/payments/crypto_checkout_link//status for the status of the checkout. const fetchStatus = async () => { fetch( `https://api.getchange.io/api/v1/payments/crypto_checkout_link/${this.coinbaseCheckoutExternalId}/status` ) .then((response) => response.json()) .then((response) => { // If the checkout has been completed, redirect to the success-url if provided. if (response.status === "completed") { this.state = "paid"; if (this.successUrl !== undefined) { (window.location as any) = this.successUrl; } return; } // ...otherwise, keeping polling. setTimeout(fetchStatus, 5000); }); }; setTimeout(fetchStatus, 5000); } private handleSolanaClick() { if (this.checkoutLinksDisabled()) { return; } this.state = "paying"; } private get solanaCheckoutLink() { if (!this.formValid) { return undefined; } const params = new URLSearchParams(); params.set("amount-usd", this.amount.toString()); params.set("first-name", this.firstNameInput?.value); params.set("last-name", this.lastNameInput?.value); params.set("donation-intent", ""); if (this.publicKey !== undefined) { params.set("public_key", this.publicKey); } return `https://getchange.io/solana/cause/${ this.solanaAddress }?${params.toString()}`; } updated(changedProperties: PropertyValues) { // Re-fetch the solana address whenever the nonprofit id changes if (changedProperties.has("nonprofitId")) { const params = new URLSearchParams(); params.append("ids[]", this.nonprofitId!); if (this.publicKey !== undefined) { params.append("public_key", this.publicKey); } fetch( `https://api.getchange.io/api/v1/nonprofit_basics?${params.toString()}`, { method: "GET" } ) .then((response) => response.json()) .then( (response) => response.nonprofits[0].crypto.solana_address as string ) .then((solanaAddress) => (this.solanaAddress = solanaAddress)); } } private handleFormInput() { this.checkFormValidity(); this.requestUpdate(); } handleQuickAmountClick(i: number) { this.selectedQuickAmount = i; this.customAmountInput.value = ""; this.checkFormValidity(); } handleCustomAmountInput() { this.selectedQuickAmount = undefined; } private projectSelected() { return !!this.selectedProjectId(); } private selectedProjectId() { return this.projectSelect?.options[this.projectSelect.selectedIndex].value; } checkFormValidity() { const amountValid = this.amount > 0; const nameValid = this.firstNameInput.validity.valid && this.lastNameInput.validity.valid; this.formValid = amountValid && nameValid; } static styles = css` :host { display: block; font-family: sans-serif; background-color: var(--background-color, #f7f9fa); max-width: 400px; } form { color: var(--color, black); display: flex; flex-direction: column; align-items: center; padding: 1.5em; } form > * { width: 100%; max-width: 100%; } form > *:not(:last-child) { margin-bottom: 0.8em; } h1 { margin: 0.5em 0; font-size: 1.5em; text-align: center; } button { transition: all 0.1s ease-out; } input, select { background: var(--input-background-color, white); } input, select.selected { color: var(--input-color, black); } input::placeholder, select { color: var(--input-placeholder-color, #999); } #amount-buttons { display: flex; } .amount-button { flex: 1; border-radius: 0; padding: 0.8em 0.6em; font-weight: bold; border: 1px solid var(--input-border-color, #ddd); color: var(--input-color, black); background: var(--input-background-color, white); border-right: none; margin: 0; } .amount-button:hover { background-color: var(--input-background-color-hover, #f5f5f5); } .amount-button:first-of-type { border-top-left-radius: var(--input-border-radius, 0.3em); border-bottom-left-radius: var(--input-border-radius, 0.3em); } .amount-button:last-of-type { border-top-right-radius: var(--input-border-radius, 0.3em); border-bottom-right-radius: var(--input-border-radius, 0.3em); border-right: 1px solid var(--input-border-color, #ddd); } .amount-button.selected { background-color: var(--background-color-primary, #1da1f2); color: var(--color-primary, white); } input[name="custom-amount"] { padding-left: 2em; } .input-container { position: relative; } .input-container input { width: 100%; } .input-prefix { display: flex; content: "$"; position: absolute; height: 100%; top: -0.04em; left: 1em; align-items: center; color: var(--input-placeholder-color, #999); } #submit-button-container { display: flex; flex-wrap: wrap; justify-content: center; } a.primary { background-color: var(--background-color-primary, #1da1f2); color: var(--color-primary, white); border-radius: 999em; padding: 0.9em 1.1em; font-weight: bold; width: 17em; max-width: 100%; margin: 0.3em; text-align: center; cursor: pointer; text-decoration: none; user-select: none; } a.primary:hover:not(:active) { background-color: var(--background-color-primary-hover, #43b2f7); } input, select { padding: 0.8em 0.6em 0.8em 1em; border: 1px solid var(--input-border-color, #ddd); border-radius: var(--input-border-radius, 0.3em); } select { /* Remove the strange padding from the left of the select options */ padding-left: calc(1em - 4px); /* Our own select arrow */ background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24' fill='none' stroke='rgba(200, 200, 200)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); background-position: right 0.6em center; background-repeat: no-repeat; -moz-appearance: none; -webkit-appearance: none; appearance: none; } a.primary.disabled { background-color: var(--background-color-disabled, #ccc) !important; color: var(--color-disabled, #fff) !important; cursor: not-allowed; } #powered-by-container { display: flex; align-items: center; justify-content: center; background-color: rgba(255, 255, 255, 0.5); color: var(--color, black); font-size: 0.8em; padding: 1em; margin-top: 0.5em; } #powered-by-container span { opacity: 0.5; } #powered-by-container img { height: 1em; margin-left: 0.4em; } /** * Loading animation */ circle { animation-name: bounce; animation-duration: 1s; animation-iteration-count: infinite; } circle:nth-of-type(2) { animation-delay: 0.1s; } circle:nth-of-type(3) { animation-delay: 0.2s; } @keyframes bounce { 0% { transform: translateY(0px); } 20% { transform: translateY(-50px); } 40% { transform: translateY(0px); } } /** * Resets */ button { cursor: pointer; border: none; } button, input, select { font-size: 1em; font-family: inherit; } * { box-sizing: border-box; } `; } function loadingImage() { return svg` `; }