diff --git a/package.json b/package.json index b484d8995a..e7c58a7fb3 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ }, "dependencies": { "@better-giving/assets": "1.0.18", + "@better-giving/donation": "1.0.1", "@better-giving/endowment": "1.0.29", + "@better-giving/fundraiser": "1.0.5", "@better-giving/registration": "1.0.24", "@better-giving/schemas": "1.0.1", "@better-giving/types": "1.0.1", @@ -58,7 +60,6 @@ "nprogress": "0.2.0", "qrcode.react": "3.1.0", "quill": "2.0.2", - "quill-delta": "5.1.0", "react": "18.2.0", "react-chariot-connect": "1.0.8", "react-csv": "2.2.2", diff --git a/src/App/App.tsx b/src/App/App.tsx index cf85dbdf50..ca81b36cdc 100644 --- a/src/App/App.tsx +++ b/src/App/App.tsx @@ -5,6 +5,7 @@ import useScrollTop from "hooks/useScrollTop"; import NProgress from "nprogress"; import { adminRoute } from "pages/Admin"; import { routes as blogRoutes } from "pages/Blog"; +import { fundsRoute } from "pages/Funds"; import { legalRoutes } from "pages/Legal"; import OAuthRedirector from "pages/OAuthRedirector"; import { profileRoute } from "pages/Profile"; @@ -39,6 +40,7 @@ const widgetRoutes: RO[] = [ const _appRoutes: RO[] = [ adminRoute, regRoute, + fundsRoute, userDashboardRoute, ...blogRoutes, ...legalRoutes, @@ -93,6 +95,10 @@ const _appRoutes: RO[] = [ const rootRoutes: RO[] = [ { path: `${appRoutes.donate}/:id`, lazy: () => import("pages/Donate") }, + { + path: `${appRoutes.donate_fund}/:id`, + lazy: () => import("pages/donate-fund"), + }, //outlet-value: isInWidget/widgetVersion { element: , children: _appRoutes }, { diff --git a/src/App/Header/UserMenu/EndowmentLink.tsx b/src/App/Header/UserMenu/EndowmentLink.tsx index b913c0dbda..c63fb141da 100644 --- a/src/App/Header/UserMenu/EndowmentLink.tsx +++ b/src/App/Header/UserMenu/EndowmentLink.tsx @@ -20,28 +20,25 @@ export function BookmarkLink({ endowId }: IBookmarkLink) { error: <_Link id={endowId} route="profile" />, }} > - {(endow) => <_Link {...endow} id={endowId} route="profile" />} + {(endow) => <_Link {...endow} id={endowId} route={appRoutes.profile} />} ); } export function EndowmentLink({ endowID, logo, name }: UserEndow) { - return <_Link id={endowID} logo={logo} name={name} route="admin" />; + return <_Link id={endowID} logo={logo} name={name} route={appRoutes.admin} />; } type LinkProps = { - id: number; + id: number | string; name?: string; logo?: string; - route: "admin" | "profile"; + route: string; }; const _Link = (props: LinkProps) => ( diff --git a/src/App/constants.ts b/src/App/constants.ts index 3d67e76b03..c5eb35c87e 100644 --- a/src/App/constants.ts +++ b/src/App/constants.ts @@ -15,6 +15,11 @@ const HEADER_LINKS: Link[] = [ href: appRoutes.marketplace, end: true, }, + { + title: "Fundraisers", + href: appRoutes.funds, + end: true, + }, { title: "Blog", href: appRoutes.blog, diff --git a/src/components/RichText/helpers.ts b/src/components/RichText/helpers.ts index 5cb8f19779..79854d6b14 100644 --- a/src/components/RichText/helpers.ts +++ b/src/components/RichText/helpers.ts @@ -1,6 +1,8 @@ -import Delta from "quill-delta"; +import Quill, { Delta } from "quill/core"; import type { RichTextContent } from "types/components"; +const quill = new Quill(document.createElement("div")); + export const parseContent = (content?: string): RichTextContent => { if (!content) return { length: 0, value: "" }; try { @@ -8,7 +10,7 @@ export const parseContent = (content?: string): RichTextContent => { const delta = new Delta(ops); return { value: content, length: delta.length() - 1 }; } catch (_) { - return { length: 0, value: "" }; + return { length: content.length, value: content }; } }; @@ -16,6 +18,15 @@ export const toDelta = (content: RichTextContent): Delta => { try { return new Delta(JSON.parse(content.value)); } catch (_) { - return new Delta(); + return new Delta([{ insert: content.value }]); + } +}; + +export const toText = (content: RichTextContent): string => { + try { + quill.setContents(toDelta(content)); + return quill.getText(); + } catch (_) { + return content.value; } }; diff --git a/src/components/RichText/index.ts b/src/components/RichText/index.ts index cc0ed6119b..70090aca54 100644 --- a/src/components/RichText/index.ts +++ b/src/components/RichText/index.ts @@ -1,3 +1,3 @@ export * from "./RichTextEditor"; -export { parseContent } from "./helpers"; +export { parseContent, toDelta, toText } from "./helpers"; export { RichText } from "./RichText"; diff --git a/src/components/VerifiedIcon.tsx b/src/components/VerifiedIcon.tsx index 01a3631521..eead247486 100644 --- a/src/components/VerifiedIcon.tsx +++ b/src/components/VerifiedIcon.tsx @@ -6,7 +6,7 @@ export default function VerifiedIcon({ size, classes = "" }: Props) { return ( + Verified diff --git a/src/components/donation/Steps/DonateMethods/Crypto/Form.test.tsx b/src/components/donation/Steps/DonateMethods/Crypto/Form.test.tsx index 45168ddd8e..16ed6241a0 100644 --- a/src/components/donation/Steps/DonateMethods/Crypto/Form.test.tsx +++ b/src/components/donation/Steps/DonateMethods/Crypto/Form.test.tsx @@ -28,7 +28,7 @@ describe("Crypto form: initial load", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "" }, + recipient: { id: "0", name: "" }, mode: "live", }; @@ -43,7 +43,7 @@ describe("Crypto form: initial load", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "", progDonationsAllowed: false }, + recipient: { id: "0", name: "", progDonationsAllowed: false }, mode: "live", }; @@ -62,7 +62,7 @@ describe("Crypto form: initial load", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "" }, + recipient: { id: "0", name: "" }, mode: "live", }; @@ -96,7 +96,7 @@ describe("Crypto form: initial load", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "" }, + recipient: { id: "0", name: "" }, mode: "live", }; @@ -120,7 +120,7 @@ describe("Crypto form: initial load", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "" }, + recipient: { id: "0", name: "" }, mode: "live", }; diff --git a/src/components/donation/Steps/DonateMethods/Crypto/Form.tsx b/src/components/donation/Steps/DonateMethods/Crypto/Form.tsx index 873b80ae29..f0b359d423 100644 --- a/src/components/donation/Steps/DonateMethods/Crypto/Form.tsx +++ b/src/components/donation/Steps/DonateMethods/Crypto/Form.tsx @@ -55,7 +55,7 @@ export default function Form(props: CryptoFormStep) { {(props.init.recipient.progDonationsAllowed ?? true) && ( diff --git a/src/components/donation/Steps/DonateMethods/Daf/Form.test.tsx b/src/components/donation/Steps/DonateMethods/Daf/Form.test.tsx index 10834e1f2c..17a2291f89 100644 --- a/src/components/donation/Steps/DonateMethods/Daf/Form.test.tsx +++ b/src/components/donation/Steps/DonateMethods/Daf/Form.test.tsx @@ -23,7 +23,7 @@ describe("DAF form test", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "" }, + recipient: { id: "0", name: "" }, mode: "live", }; @@ -39,7 +39,7 @@ describe("DAF form test", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "", progDonationsAllowed: false }, + recipient: { id: "0", name: "", progDonationsAllowed: false }, mode: "live", }; render(<_Form init={init} step="donate-form" />); @@ -53,7 +53,7 @@ describe("DAF form test", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "" }, + recipient: { id: "0", name: "" }, mode: "live", }; const details: DafDonationDetails = { @@ -75,7 +75,7 @@ describe("DAF form test", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "", progDonationsAllowed: false }, + recipient: { id: "0", name: "", progDonationsAllowed: false }, mode: "live", }; render(<_Form init={init} step="donate-form" />); diff --git a/src/components/donation/Steps/DonateMethods/Daf/Form.tsx b/src/components/donation/Steps/DonateMethods/Daf/Form.tsx index 56a01c8591..27d266ded8 100644 --- a/src/components/donation/Steps/DonateMethods/Daf/Form.tsx +++ b/src/components/donation/Steps/DonateMethods/Daf/Form.tsx @@ -55,7 +55,7 @@ export default function Form(props: Props) { {(props.init.recipient.progDonationsAllowed ?? true) && ( diff --git a/src/components/donation/Steps/DonateMethods/Stocks/Form.test.tsx b/src/components/donation/Steps/DonateMethods/Stocks/Form.test.tsx index 2a199eae85..a61b14dbd0 100644 --- a/src/components/donation/Steps/DonateMethods/Stocks/Form.test.tsx +++ b/src/components/donation/Steps/DonateMethods/Stocks/Form.test.tsx @@ -23,7 +23,7 @@ describe("stocks form test", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "" }, + recipient: { id: "0", name: "" }, mode: "live", }; render(<_Form init={init} step="donate-form" />); @@ -42,7 +42,7 @@ describe("stocks form test", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "", progDonationsAllowed: false }, + recipient: { id: "0", name: "", progDonationsAllowed: false }, mode: "live", }; render(<_Form init={init} step="donate-form" />); @@ -55,7 +55,7 @@ describe("stocks form test", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "" }, + recipient: { id: "0", name: "" }, mode: "live", }; const details: StocksDonationDetails = { @@ -84,7 +84,7 @@ describe("stocks form test", () => { const init: Init = { source: "bg-marketplace", config: null, - recipient: { id: 0, name: "", progDonationsAllowed: false }, + recipient: { id: "0", name: "", progDonationsAllowed: false }, mode: "live", }; render(<_Form init={init} step="donate-form" />); diff --git a/src/components/donation/Steps/DonateMethods/Stocks/Form.tsx b/src/components/donation/Steps/DonateMethods/Stocks/Form.tsx index 2b2e2f9268..a95b107733 100644 --- a/src/components/donation/Steps/DonateMethods/Stocks/Form.tsx +++ b/src/components/donation/Steps/DonateMethods/Stocks/Form.tsx @@ -87,7 +87,7 @@ export default function Form(props: StockFormStep) { {(props.init.recipient.progDonationsAllowed ?? true) && ( diff --git a/src/components/donation/Steps/DonateMethods/Stripe/Form.test.tsx b/src/components/donation/Steps/DonateMethods/Stripe/Form.test.tsx index dd955613b6..aa65baa943 100644 --- a/src/components/donation/Steps/DonateMethods/Stripe/Form.test.tsx +++ b/src/components/donation/Steps/DonateMethods/Stripe/Form.test.tsx @@ -23,7 +23,7 @@ const init: Init = { source: "bg-marketplace", mode: "live", recipient: { - id: 123456, + id: "123456", name: "Example Endowment", }, config: null, @@ -93,7 +93,7 @@ describe("Stripe form test", () => { source: "bg-marketplace", mode: "live", recipient: { - id: 123456, + id: "123456", name: "Example Endowment", progDonationsAllowed: false, }, diff --git a/src/components/donation/Steps/DonateMethods/Stripe/Form.tsx b/src/components/donation/Steps/DonateMethods/Stripe/Form.tsx index 8873c08e98..c220efc0cf 100644 --- a/src/components/donation/Steps/DonateMethods/Stripe/Form.tsx +++ b/src/components/donation/Steps/DonateMethods/Stripe/Form.tsx @@ -89,7 +89,7 @@ function Form({ currencies, defaultCurr, ...props }: FormProps) { {(props.init.recipient.progDonationsAllowed ?? true) && ( diff --git a/src/components/donation/Steps/Steps.test.tsx b/src/components/donation/Steps/Steps.test.tsx index 5778ee0788..23f2f0e548 100644 --- a/src/components/donation/Steps/Steps.test.tsx +++ b/src/components/donation/Steps/Steps.test.tsx @@ -24,7 +24,7 @@ describe("donation flow", () => { const state: DonationState = { step: "donate-form", init: { - recipient: { id: 1, name: "test" }, + recipient: { id: "1", name: "test" }, source: "bg-marketplace", mode: "live", config: null, @@ -53,7 +53,7 @@ describe("donation flow", () => { const state: DonationState = { step: "donate-form", init: { - recipient: { id: 1, name: "test", hide_bg_tip: true }, + recipient: { id: "1", name: "test", hide_bg_tip: true }, source: "bg-marketplace", mode: "live", config: null, @@ -86,7 +86,7 @@ describe("donation flow", () => { const state: DonationState = { step: "tip", init: { - recipient: { id: 1, name: "test" }, + recipient: { id: "1", name: "test" }, source: "bg-marketplace", mode: "live", config: null, @@ -129,7 +129,7 @@ describe("donation flow", () => { const state: DonationState = { step: "tip", init: { - recipient: { id: 1, name: "test" }, + recipient: { id: "1", name: "test" }, source: "bg-marketplace", mode: "live", config: null, diff --git a/src/components/donation/Steps/Submit/Crypto/DirectMode.tsx b/src/components/donation/Steps/Submit/Crypto/DirectMode.tsx index 7f6cf80ce9..e10e4b3405 100644 --- a/src/components/donation/Steps/Submit/Crypto/DirectMode.tsx +++ b/src/components/donation/Steps/Submit/Crypto/DirectMode.tsx @@ -1,4 +1,5 @@ import chains from "@better-giving/assets/chains"; +import type { DonationIntent } from "@better-giving/donation/intent"; import ContentLoader from "components/ContentLoader"; import QueryLoader from "components/QueryLoader"; import { appRoutes } from "constants/routes"; @@ -27,29 +28,37 @@ export default function DirectMode({ donation, classes = "" }: Props) { honorary, } = donation; - const intentQuery = useCreateCryptoIntentQuery({ - transactionId: init.intentId, - amount: +details.token.amount, - tipAmount: tip?.value ?? 0, - feeAllowance, - chainId: details.token.network, - chainName: chains[details.token.network].name, - denomination: details.token.code, - splitLiq: 0, - endowmentId: init.recipient.id, + const intent: DonationIntent = { + frequency: "one-time", + amount: { + amount: +details.token.amount, + currency: details.token.code, + tip: tip?.value ?? 0, + feeAllowance, + }, + viaId: details.token.network, + viaName: chains[details.token.network].name, + recipient: init.recipient.id, source: init.source, donor: toDonor(fvDonor), - ...(honorary.honoraryFullName && { - inHonorOf: honorary.honoraryFullName, - tributeNotif: honorary.withTributeNotif - ? honorary.tributeNotif - : undefined, - }), - ...(details.program.value && { - programId: details.program.value, - programName: details.program.label, - }), - }); + }; + if (honorary.honoraryFullName) { + intent.tribute = { + fullName: honorary.honoraryFullName, + }; + if (honorary.withTributeNotif) { + intent.tribute.notif = honorary.tributeNotif; + } + } + + if (details.program.value) { + intent.program = { + id: details.program.value, + name: details.program.label, + }; + } + + const intentQuery = useCreateCryptoIntentQuery(intent); const totalDisplayAmount = roundToCents( +details.token.amount + (tip?.value ?? 0) + feeAllowance, diff --git a/src/components/donation/Steps/Submit/DAFCheckout/ChariotCheckout.tsx b/src/components/donation/Steps/Submit/DAFCheckout/ChariotCheckout.tsx index d15adb1fd0..4f99ed1acc 100644 --- a/src/components/donation/Steps/Submit/DAFCheckout/ChariotCheckout.tsx +++ b/src/components/donation/Steps/Submit/DAFCheckout/ChariotCheckout.tsx @@ -1,3 +1,4 @@ +import type { DonationIntent } from "@better-giving/donation/intent"; import { yupResolver } from "@hookform/resolvers/yup"; import ContentLoader from "components/ContentLoader"; import Prompt from "components/Prompt"; @@ -276,14 +277,17 @@ export default function ChariotCheckout(props: DafCheckoutStep) { const { postalCode, line1, line2, city, state } = grantor.address; - await createGrant({ - transactionId: workflowSessionId, - amount: adjusted.amount, - tipAmount: adjusted.tip, - feeAllowance: adjusted.feeAllowance, - currency: props.details.currency.code, - endowmentId: props.init.recipient.id, - splitLiq: 0, + const intent: DonationIntent = { + frequency: "one-time", + viaId: workflowSessionId, + viaName: "", + amount: { + currency: props.details.currency.code, + amount: adjusted.amount, + tip: adjusted.tip, + feeAllowance: adjusted.feeAllowance, + }, + recipient: props.init.recipient.id, donor: toDonor({ title: initDonorTitleOption, email: grantor.email, @@ -296,17 +300,25 @@ export default function ChariotCheckout(props: DafCheckoutStep) { ukTaxResident: meta.ukTaxResident, }), source: props.init.source, - ...(props.details.program.value && { - programId: props.details.program.value, - programName: props.details.program.label, - }), - ...(meta.honoraryFullName && { - inHonorOf: meta.honoraryFullName, - tributeNotif: meta.withTributeNotif - ? meta.tributeNotif - : undefined, - }), - }).unwrap(); + }; + + if (props.details.program.value) { + intent.program = { + id: props.details.program.value, + name: props.details.program.label, + }; + } + + if (meta.honoraryFullName) { + intent.tribute = { + fullName: meta.honoraryFullName, + }; + if (meta.withTributeNotif) { + intent.tribute.notif = meta.tributeNotif; + } + } + + await createGrant(intent).unwrap(); setModalOption("isDismissible", true); closeModal(); diff --git a/src/components/donation/Steps/Submit/StripeCheckout/Paypal/Checkout.tsx b/src/components/donation/Steps/Submit/StripeCheckout/Paypal/Checkout.tsx index 6469fea3fe..3700d00bf8 100644 --- a/src/components/donation/Steps/Submit/StripeCheckout/Paypal/Checkout.tsx +++ b/src/components/donation/Steps/Submit/StripeCheckout/Paypal/Checkout.tsx @@ -1,3 +1,4 @@ +import type { DonationIntent } from "@better-giving/donation/intent"; import { PayPalButtons, usePayPalScriptReducer } from "@paypal/react-paypal-js"; import ContentLoader from "components/ContentLoader"; import { appRoutes, donateWidgetRoutes } from "constants/routes"; @@ -76,26 +77,40 @@ export default function Checkout(props: StripeCheckoutStep) { navigate(route, { state }); }} - createOrder={async () => - await createOrder({ - transactionId: init.intentId, - amount: +details.amount, - tipAmount: tip?.value ?? 0, - feeAllowance, - currency: details.currency.code, - endowmentId: init.recipient.id, - splitLiq: 0, + createOrder={async () => { + const intent: DonationIntent = { + amount: { + amount: +details.amount, + tip: tip?.value ?? 0, + feeAllowance, + currency: details.currency.code, + }, + frequency: "one-time", + recipient: init.recipient.id, donor: toDonor(fvDonor), + viaId: "paypal", + viaName: "Paypal", source: init.source, - ...(honorary.honoraryFullName && { - inHonorOf: honorary.honoraryFullName, - }), - ...(details.program.value && { - programId: details.program.value, - programName: details.program.label, - }), - }).unwrap() - } + }; + if (honorary.honoraryFullName) { + intent.tribute = { + fullName: honorary.honoraryFullName, + }; + if (honorary.withTributeNotif) { + intent.tribute.notif = honorary.tributeNotif; + } + } + + if (details.program.value) { + intent.program = { + id: details.program.value, + name: details.program.label, + }; + } + + const res = await createOrder(intent).unwrap(); + return res; + }} /> ); } diff --git a/src/components/donation/Steps/Submit/StripeCheckout/StripeCheckout.test.tsx b/src/components/donation/Steps/Submit/StripeCheckout/StripeCheckout.test.tsx index eaf2285701..27fd9a4a86 100644 --- a/src/components/donation/Steps/Submit/StripeCheckout/StripeCheckout.test.tsx +++ b/src/components/donation/Steps/Submit/StripeCheckout/StripeCheckout.test.tsx @@ -53,7 +53,7 @@ const Checkout: typeof StripeCheckout = (props) => ( const state: StripeCheckoutStep = { init: { - recipient: { id: 1, name: "test" }, + recipient: { id: "1", name: "test" }, source: "bg-marketplace", mode: "live", config: null, diff --git a/src/components/donation/Steps/Submit/StripeCheckout/StripeCheckout.tsx b/src/components/donation/Steps/Submit/StripeCheckout/StripeCheckout.tsx index 1f0fae7b50..83dee7542a 100644 --- a/src/components/donation/Steps/Submit/StripeCheckout/StripeCheckout.tsx +++ b/src/components/donation/Steps/Submit/StripeCheckout/StripeCheckout.tsx @@ -1,3 +1,4 @@ +import type { DonationIntent } from "@better-giving/donation/intent"; import { Elements } from "@stripe/react-stripe-js"; import { loadStripe } from "@stripe/stripe-js"; import { PUBLIC_STRIPE_KEY } from "constants/env"; @@ -21,33 +22,43 @@ export default function StripeCheckout(props: StripeCheckoutStep) { const { init, details, tip, donor: fvDonor, honorary, feeAllowance } = props; const { setState } = useDonationState(); + const intent: DonationIntent = { + frequency: details.frequency === "subscription" ? "recurring" : "one-time", + amount: { + amount: +details.amount, + tip: tip?.value ?? 0, + feeAllowance, + currency: details.currency.code, + }, + recipient: init.recipient.id, + donor: toDonor(fvDonor), + source: init.source, + viaId: "fiat", + viaName: "Stripe", + }; + + if (honorary.honoraryFullName) { + intent.tribute = { + fullName: honorary.honoraryFullName, + }; + if (honorary.withTributeNotif) { + intent.tribute.notif = honorary.tributeNotif; + } + } + + if (details.program.value) { + intent.program = { + id: details.program.value, + name: details.program.label, + }; + } + const { data: clientSecret, isLoading, isError, error, - } = useStripePaymentIntentQuery({ - transactionId: init.intentId, - type: details.frequency, - amount: +details.amount, - tipAmount: tip?.value ?? 0, - feeAllowance, - currency: details.currency.code, - endowmentId: init.recipient.id, - splitLiq: 0, - donor: toDonor(fvDonor), - source: init.source, - ...(honorary.honoraryFullName && { - inHonorOf: honorary.honoraryFullName, - tributeNotif: honorary.withTributeNotif - ? honorary.tributeNotif - : undefined, - }), - ...(details.program.value && { - programId: details.program.value, - programName: details.program.label, - }), - }); + } = useStripePaymentIntentQuery(intent); return ( ReactNode; onBack(): void; - frequency?: DonationIntent.Frequency; + frequency?: Frequency; classes?: Classes; children?: ReactNode; preSplitContent?: ReactNode; diff --git a/src/components/donation/Steps/common/constants.ts b/src/components/donation/Steps/common/constants.ts index 08af0dc2bd..e9854ba71c 100644 --- a/src/components/donation/Steps/common/constants.ts +++ b/src/components/donation/Steps/common/constants.ts @@ -1,5 +1,5 @@ +import type { Donor } from "@better-giving/donation/intent"; import type { DonateMethodId } from "@better-giving/endowment"; -import type { Donor } from "types/aws"; import type { DetailedCurrency, OptionType, @@ -94,13 +94,13 @@ export const toDonor = (fv: FormDonor): Donor => { lastName: fv.lastName, address: fv.ukTaxResident ? { - streetAddress: fv.streetAddress, + street: fv.streetAddress, city: "", zipCode: fv.zipCode, country: "United Kingdom", + ukGiftAid: fv.ukTaxResident, } : undefined, - ukGiftAid: fv.ukTaxResident, }; }; diff --git a/src/components/donation/Steps/index.tsx b/src/components/donation/Steps/index.tsx index 08dad68f10..3db436aa9f 100644 --- a/src/components/donation/Steps/index.tsx +++ b/src/components/donation/Steps/index.tsx @@ -1,18 +1,10 @@ -import type { DonationIntent } from "types/aws"; -import type { OptionType } from "types/components"; import type { DonationSource } from "types/lists"; import Context from "./Context"; import CurrentStep from "./CurrentStep"; -import { - initDetails, - initDonorTitleOption, - initTributeNotif, -} from "./common/constants"; import type { Config, DonationRecipient, DonationState, - FormDonor, Init, Mode, } from "./types"; @@ -22,9 +14,8 @@ type Components = { mode: Mode; config: Config | null; recipient: DonationRecipient; - programId?: string; - intent?: DonationIntent.ToResume; }; + type InitState = { init: DonationState; }; @@ -47,10 +38,8 @@ export function Steps({ className = "", ...props }: Props) { function initialState({ source, - intent, config, recipient, - programId, mode, }: Components): DonationState { const init: Init = { @@ -58,93 +47,10 @@ function initialState({ config, recipient, mode, - intentId: intent?.transactionId, - }; - - if (!intent) { - return { - step: "donate-form", - init, - details: initDetails( - init.config?.methodIds?.at(0) ?? "stripe", - programId - ), - }; - } - - const program: OptionType = { - //label would be replaced once program options are loaded - label: "General donation", - value: intent.programId ?? programId ?? "", }; - const { email, firstName, lastName, ...d } = intent.donor; - const formDonor: FormDonor = { - email, - firstName, - lastName, - ukTaxResident: d.address?.country === "United Kingdom", - title: d.title ? { label: d.title, value: d.title } : initDonorTitleOption, - streetAddress: d.address?.streetAddress ?? "", - zipCode: d.address?.zipCode ?? "", - }; - - if ("chainId" in intent) { - return { - init, - step: "submit", - details: { - method: "crypto", - token: { - amount: `${intent.amount}`, - min: intent.token.min_donation_amnt, - id: intent.token.token_id, - code: intent.denomination, - cg_id: intent.token.coingecko_denom, - logo: intent.token.logo, - network: "", - color: "", - name: "", - precision: 0, - symbol: "", - rate: 0, - }, - - program, - }, - tip: { value: intent.tipAmount, format: "pct" }, - donor: formDonor, - honorary: { - withHonorary: !!intent.inHonorOf, - honoraryFullName: intent.inHonorOf ?? "", - withTributeNotif: !!intent.tributeNotif, - tributeNotif: intent.tributeNotif ?? initTributeNotif, - }, - feeAllowance: intent.feeAllowance, - }; - } return { init, - step: "submit", - details: { - method: "stripe", - amount: `${intent.amount}`, - currency: { - code: intent.currency.currency_code, - min: intent.currency.minimum_amount, - rate: intent.currency.rate, - }, - frequency: intent.frequency, - program, - }, - tip: { value: intent.tipAmount, format: "pct" }, - donor: formDonor, - honorary: { - withHonorary: !!intent.inHonorOf, - honoraryFullName: intent.inHonorOf ?? "", - withTributeNotif: !!intent.tributeNotif, - tributeNotif: intent.tributeNotif ?? initTributeNotif, - }, - feeAllowance: intent.feeAllowance, + step: "donate-form", }; } diff --git a/src/components/donation/Steps/types.ts b/src/components/donation/Steps/types.ts index 709e0f810c..850549e5d5 100644 --- a/src/components/donation/Steps/types.ts +++ b/src/components/donation/Steps/types.ts @@ -1,5 +1,5 @@ -import type { DonateMethodId, Endow } from "@better-giving/endowment"; -import type { DonationIntent, Donor } from "types/aws"; +import type { DonateMethodId } from "@better-giving/endowment"; +import type { Donor } from "types/aws"; import type { DetailedCurrency, OptionType, @@ -8,15 +8,19 @@ import type { import type { DonationSource } from "types/lists"; import type { Increment } from "types/widget"; +export type Frequency = "one-time" | "subscription"; + type From = Omit< Required, "step" | U > & { [key in U]?: T[key] }; -export type DonationRecipient = Pick< - Endow, - "id" | "name" | "hide_bg_tip" | "progDonationsAllowed" ->; +export type DonationRecipient = { + id: string; + name: string; + hide_bg_tip?: boolean; + progDonationsAllowed?: boolean; +}; type BaseDonationDetails = { /** value is "" if no program is selected */ @@ -35,7 +39,7 @@ type FiatDonationDetails = BaseDonationDetails & { export type StripeDonationDetails = { method: Extract; - frequency: DonationIntent.Frequency; + frequency: Frequency; } & FiatDonationDetails; export type StocksDonationDetails = BaseDonationDetails & { @@ -70,8 +74,6 @@ export type Init = { mode: Mode; recipient: DonationRecipient; config: Config | null; - /** intent to resume */ - intentId?: string; }; export type FormStep = { diff --git a/src/constants/routes.ts b/src/constants/routes.ts index d894c532a8..02c506bc4c 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -11,6 +11,7 @@ export enum appRoutes { signup = "/signup", profile = "/profile", donate = "/donate", + donate_fund = "/donate-fund", donate_thanks = "/donate-thanks", stripe_payment_status = "/stripe-payment-status", donate_widget = "/donate-widget", @@ -25,6 +26,7 @@ export enum appRoutes { nonprofit_info = "/nonprofit", donor_info = "/donor", wp_plugin = "/wp-plugin", + funds = "/funds", about = "/about-us", } @@ -39,6 +41,7 @@ export const adminRoutes = { settings: "settings", members: "members", media: "media", + funds: "funds", } as const; export enum donateWidgetRoutes { diff --git a/src/helpers/uploadFile.ts b/src/helpers/uploadFile.ts index 810cf31764..a6e1f0db5e 100644 --- a/src/helpers/uploadFile.ts +++ b/src/helpers/uploadFile.ts @@ -4,7 +4,7 @@ import { toast } from "sonner"; import { jwtToken } from "./jwt-token"; import { logger } from "./logger"; -export type Bucket = "endow-profiles" | "endow-reg" | "bg-user"; +export type Bucket = "endow-profiles" | "endow-reg" | "bg-user" | "bg-funds"; export const bucketURL = "s3.amazonaws.com"; const SPACES = /\s+/g; diff --git a/src/pages/Admin/Charity/Funds/FundItem.tsx b/src/pages/Admin/Charity/Funds/FundItem.tsx new file mode 100644 index 0000000000..c2a05c861a --- /dev/null +++ b/src/pages/Admin/Charity/Funds/FundItem.tsx @@ -0,0 +1,126 @@ +import type { FundItem as TFundItem } from "@better-giving/fundraiser"; +import Prompt from "components/Prompt"; +import { appRoutes } from "constants/routes"; +import { useAuthenticatedUser } from "contexts/Auth"; +import { useErrorContext } from "contexts/ErrorContext"; +import { useModalContext } from "contexts/ModalContext"; +import { ArrowRight, BadgeCheck, LoaderCircle, Split } from "lucide-react"; +import { Link } from "react-router-dom"; +import { + useApproveMutation, + useOptOutMutation, +} from "services/aws/endow-funds"; + +export const FundItem = (props: TFundItem & { endowId: number }) => { + const user = useAuthenticatedUser(); + const isActive = new Date().toISOString() <= props.expiration && props.active; + const isEditor = user.funds.includes(props.id); + const [optOut, { isLoading: isOptingOut }] = useOptOutMutation(); + const [approve, { isLoading: isApproving }] = useApproveMutation(); + const { showModal } = useModalContext(); + const { handleError } = useErrorContext(); + + const isApproved = props.approvers.includes(props.endowId); + + return ( +
+ + + {props.name} + + + {isActive ? "active" : "closed"} + + + +
+ + + Edit + + {/** fund item won't show once NPO opted out of it: so no need to hide this button */} + + {!isApproved ? ( + + ) : ( +
+ +

Approved

+
+ )} +
+
+ ); +}; diff --git a/src/pages/Admin/Charity/Funds/Funds.tsx b/src/pages/Admin/Charity/Funds/Funds.tsx new file mode 100644 index 0000000000..c1964afc6a --- /dev/null +++ b/src/pages/Admin/Charity/Funds/Funds.tsx @@ -0,0 +1,50 @@ +import ContentLoader from "components/ContentLoader"; +import QueryLoader from "components/QueryLoader"; +import { useFundsEndowMemberOfQuery } from "services/aws/endow-funds"; +import { useAdminContext } from "../../Context"; +import { FundItem } from "./FundItem"; + +export function Funds() { + const { id } = useAdminContext(); + const query = useFundsEndowMemberOfQuery({ endowId: id }); + return ( +
+

Fundraisers

+ + + + + + + ), + error: "Failed to get fundraisers", + empty: "This NPO has not been included in any fundraisers", + }} + queryState={query} + > + {(funds) => ( + <> + {funds.map((fund) => ( + + ))} + + )} + +
+ ); +} + +export function Skeleton() { + return ( +
+ + +
+ ); +} diff --git a/src/pages/Admin/Charity/Funds/index.ts b/src/pages/Admin/Charity/Funds/index.ts new file mode 100644 index 0000000000..0f4842aa81 --- /dev/null +++ b/src/pages/Admin/Charity/Funds/index.ts @@ -0,0 +1 @@ +export { Funds as Component } from "./Funds"; diff --git a/src/pages/Admin/Charity/Settings/Form.tsx b/src/pages/Admin/Charity/Settings/Form.tsx index fa92c9652d..de1861d90d 100644 --- a/src/pages/Admin/Charity/Settings/Form.tsx +++ b/src/pages/Admin/Charity/Settings/Form.tsx @@ -43,6 +43,7 @@ export default function Form(props: Props) { programDonateDisabled: !(props.progDonationsAllowed ?? true), donateMethods: fill(props.donateMethods), increments: props.increments ?? [], + fundOptIn: props.fund_opt_in ?? false, target: toFormTarget(props.target), }, }); @@ -76,6 +77,7 @@ export default function Form(props: Props) { async ({ programDonateDisabled, donateMethods, + fundOptIn, target: fvTarget, ...fv }) => { @@ -87,6 +89,7 @@ export default function Form(props: Props) { await updateEndow({ ...fv, + fund_opt_in: fundOptIn, target: toTarget(fvTarget), progDonationsAllowed: !programDonateDisabled, id: props.id, @@ -152,6 +155,19 @@ export default function Form(props: Props) { +
+ + Allow Fundraisers to be created on behalf of your nonprofit + +

+ Fundraising functionality is optional for all Better Giving + nonprofits. By opting in, people will be able to create fundraisers on + your behalf. You will receive 100% of funds raised for fundraisers + specific to your organization, and a percentage split of fundraisers + involving multiple nonprofits (such as curated giving indexes). +

+
+
Marketplace settings
; diff --git a/src/pages/Admin/Charity/Settings/types.ts b/src/pages/Admin/Charity/Settings/types.ts index acbeb7187f..3dd470ec09 100644 --- a/src/pages/Admin/Charity/Settings/types.ts +++ b/src/pages/Admin/Charity/Settings/types.ts @@ -34,6 +34,7 @@ export const schema = v.object({ ({ requirement }) => `cannot exceed ${requirement} characters` ) ), + fundOptIn: v.boolean(), hide_bg_tip: v.boolean(), programDonateDisabled: v.boolean(), increments: v.array(increment), diff --git a/src/pages/Admin/Charity/index.tsx b/src/pages/Admin/Charity/index.tsx index d4a81ab3e8..3351109cf8 100644 --- a/src/pages/Admin/Charity/index.tsx +++ b/src/pages/Admin/Charity/index.tsx @@ -35,6 +35,7 @@ export const charityRoute: RouteObject = { ], }, { path: adminRoutes.form_builder, element: }, + { path: adminRoutes.funds, lazy: () => import("./Funds") }, { index: true, lazy: () => import("./Dashboard") }, ...mediaRoutes, ], diff --git a/src/pages/Admin/constants.ts b/src/pages/Admin/constants.ts index 09894a1136..217a049392 100644 --- a/src/pages/Admin/constants.ts +++ b/src/pages/Admin/constants.ts @@ -4,6 +4,7 @@ import { Blocks, CircleDollarSign, CircleUserRound, + Heart, Image, LayoutDashboard, ListCheck, @@ -106,6 +107,14 @@ const linkGroup3: LinkGroup = { size: 25, }, }, + { + title: "Fundraisers", + to: sidebarRoutes.funds, + icon: { + fn: Heart, + size: 21, + }, + }, ], }; diff --git a/src/pages/Donate/Content.tsx b/src/pages/Donate/Content.tsx index 37921d59e1..9ff7389e99 100644 --- a/src/pages/Donate/Content.tsx +++ b/src/pages/Donate/Content.tsx @@ -6,20 +6,16 @@ import { Steps } from "components/donation"; import { INTERCOM_HELP } from "constants/env"; import { appRoutes } from "constants/routes"; import { PRIVACY_POLICY } from "constants/urls"; -import { useRendered } from "hooks/use-rendered"; import { memo } from "react"; import { Link } from "react-router-dom"; -import type { DonationIntent } from "types/aws"; import FAQ from "./FAQ"; import OrgCard from "./OrgCard"; type Props = { - intent?: DonationIntent.ToResume; endowment: Endow; }; -function Content({ intent, endowment }: Props) { - useRendered(); +function Content({ endowment }: Props) { return (
@@ -48,9 +44,8 @@ function Content({ intent, endowment }: Props) { { - window.history.replaceState({}, ""); - }, []); - const { id } = useParams<{ id: string }>(); const numId = idParamToNum(id); @@ -42,7 +30,7 @@ export function Component() { image={profile.logo} url={`${BASE_URL}/donate/${profile.id}`} /> - + )} diff --git a/src/pages/DonateThanks.tsx b/src/pages/DonateThanks.tsx index 97195e9bb3..ba08515a52 100644 --- a/src/pages/DonateThanks.tsx +++ b/src/pages/DonateThanks.tsx @@ -52,11 +52,6 @@ export function Component() { />

- {state?.microdepositArrivalDate - ? `The microdeposit is expected to arrive at your nominated bank account on ${new Date( - state.microdepositArrivalDate * 1000 - )}. You can access the bank verification link from the "Pending" tab on your` - : "If you need a receipt for your donation, please fill out the KYC form for this transaction on your"}{" "} {widgetVersion ? ( My Donations @@ -67,16 +62,6 @@ export function Component() { page.

- {!userIsSignedIn(user) && - state?.guestDonor && - state?.bankVerificationUrl && ( -

- If you are not signed up yet, you may access the bank verification - url by copying{" "} - this link. -

- )} - {!userIsSignedIn(user) && state?.guestDonor && (
); diff --git a/src/pages/Funds/Cards/Card.tsx b/src/pages/Funds/Cards/Card.tsx new file mode 100644 index 0000000000..21d9942f0e --- /dev/null +++ b/src/pages/Funds/Cards/Card.tsx @@ -0,0 +1,77 @@ +import type { FundItem } from "@better-giving/fundraiser"; +import flying_character from "assets/images/flying-character.png"; +import Image from "components/Image"; +import { parseContent, toText } from "components/RichText"; +import VerifiedIcon from "components/VerifiedIcon"; +import { Target, toTarget } from "components/target"; +import { appRoutes } from "constants/routes"; +import { Link } from "react-router-dom"; + +export default function Card({ + name, + logo, + banner, + id, + description, + verified, + donation_total_usd, + target, +}: FundItem) { + return ( +
+ +
+ e.currentTarget.classList.add("bg-blue-l3")} + /> + e.currentTarget.classList.add("bg-blue-l3")} + /> + {verified && ( +
+ +
+ )} +
+ +
+ {/* nonprofit NAME */} +

+ {name} +

+ +

+ {toText(parseContent(description))} +

+ + +
+ + {/** absolute so above whole `Link` card */} +
+
{/** future: share button */} + + Donate + +
{/** future: bookmark button */} +
+
+ ); +} diff --git a/src/pages/Funds/Cards/Cards.tsx b/src/pages/Funds/Cards/Cards.tsx new file mode 100644 index 0000000000..460c8414f8 --- /dev/null +++ b/src/pages/Funds/Cards/Cards.tsx @@ -0,0 +1,58 @@ +import QueryLoader from "components/QueryLoader"; +import Card from "./Card"; +import useCards from "./useCards"; + +interface Props { + classes?: string; + search: string; +} + +export default function Cards({ classes = "", search }: Props) { + const { + hasMore, + isFetching, + isLoading, + isLoadingNextPage, + loadNextPage, + data, + isError, + } = useCards(search); + return ( + + {(funds) => ( +
+ {funds.map((fund) => ( + + ))} + + {hasMore && ( + + )} +
+ )} +
+ ); +} diff --git a/src/pages/Funds/Cards/index.ts b/src/pages/Funds/Cards/index.ts new file mode 100644 index 0000000000..d70ef1788f --- /dev/null +++ b/src/pages/Funds/Cards/index.ts @@ -0,0 +1 @@ +export { default } from "./Cards"; diff --git a/src/pages/Funds/Cards/useCards.ts b/src/pages/Funds/Cards/useCards.ts new file mode 100644 index 0000000000..5a9447101f --- /dev/null +++ b/src/pages/Funds/Cards/useCards.ts @@ -0,0 +1,62 @@ +import useDebouncer from "hooks/useDebouncer"; +import { + updateAwsQueryData, + useFundsQuery, + useLazyFundsQuery, +} from "services/aws/funds"; +import { useSetter } from "store/accessors"; + +export default function useCards(search: string) { + const [debouncedSearchText, isDebouncing] = useDebouncer(search, 500); + const dispatch = useSetter(); + + const { + isLoading, + isFetching, + isUninitialized, + data, + isError, + originalArgs, + } = useFundsQuery( + { query: debouncedSearchText, page: 1 }, + { skip: isDebouncing } + ); + + const [loadMore, { isLoading: isLoadingNextPage }] = useLazyFundsQuery(); + + async function loadNextPage() { + //button is hidden when there's no more + if ( + data?.page && + originalArgs /** cards won't even show if no initial query is made */ + ) { + const { data: newPage } = await loadMore({ + ...originalArgs, + page: data.page + 1, + }); + + if (newPage) { + //pessimistic update to original cache data + dispatch( + updateAwsQueryData("funds", originalArgs, (prevResult) => { + prevResult.items.push(...newPage.items); + prevResult.page = newPage.page; + }) + ); + } + } + } + + // initial assumption is that there's no more to load until we get first res from query + const hasMore = (data?.page || 0) < (data?.numPages || 0); + + return { + hasMore, + isLoading: isLoading || isUninitialized, + isLoadingNextPage: isLoadingNextPage, + isFetching: isFetching || isUninitialized, + loadNextPage, + data, + isError, + }; +} diff --git a/src/pages/Funds/CreateFund/CreateFund.tsx b/src/pages/Funds/CreateFund/CreateFund.tsx new file mode 100644 index 0000000000..83375f3437 --- /dev/null +++ b/src/pages/Funds/CreateFund/CreateFund.tsx @@ -0,0 +1,269 @@ +import type { NewFund } from "@better-giving/fundraiser/schema"; +import { valibotResolver } from "@hookform/resolvers/valibot"; +import { ControlledImgEditor as ImgEditor } from "components/ImgEditor"; +import Prompt from "components/Prompt"; +import { RichText } from "components/RichText"; +import { + NativeCheckField as CheckField, + NativeField as Field, + Form, + Label, +} from "components/form"; +import { appRoutes } from "constants/routes"; +import withAuth from "contexts/Auth"; +import { useErrorContext } from "contexts/ErrorContext"; +import { useModalContext } from "contexts/ModalContext"; +import { uploadFile } from "helpers/uploadFile"; +import { + type SubmitHandler, + useController, + useFieldArray, + useForm, +} from "react-hook-form"; +import { Link } from "react-router-dom"; +import { useCreateFundMutation } from "services/aws/funds"; +import { GoalSelector, MAX_SIZE_IN_BYTES, VALID_MIME_TYPES } from "../common"; +import { Videos } from "../common/videos"; +import { EndowmentSelector } from "./EndowmentSelector"; +import { type FV, MAX_DESCRIPTION_CHAR, schema } from "./schema"; + +export default withAuth(function CreateFund() { + const { + register, + control, + trigger, + resetField, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: valibotResolver(schema), + defaultValues: { + name: "", + description: "", + logo: { preview: "", publicUrl: "" }, + banner: { preview: "", publicUrl: "" }, + featured: true, + members: [], + target: { + type: "smart", + }, + videos: [], + }, + }); + const { field: banner } = useController({ control, name: "banner" }); + const { field: logo } = useController({ control, name: "logo" }); + const { field: members } = useController({ + control, + name: "members", + }); + const { field: targetType } = useController({ + control, + name: "target.type", + }); + const { field: desc } = useController({ + control, + name: "description", + }); + + const videos = useFieldArray, "videos">({ + control: control as any, + name: "videos", + }); + + const [createFund] = useCreateFundMutation(); + const { handleError } = useErrorContext(); + const { showModal } = useModalContext(); + + const onSubmit: SubmitHandler = async ({ banner, logo, ...fv }) => { + try { + if (!banner.file || !logo.file) { + throw `dev: banner must be required`; + } + + showModal(Prompt, { type: "loading", children: "Creating fund..." }); + + const _banner = await uploadFile(banner.file, "bg-funds"); + if (!_banner) return handleError("Failed to upload banner"); + const _logo = await uploadFile(logo.file, "bg-funds"); + if (!_logo) return handleError("Failed to upload logo"); + + const fund: NewFund = { + name: fv.name, + description: fv.description.value, + banner: _banner.publicUrl, + logo: _logo.publicUrl, + members: fv.members.map((m) => m.id), + featured: fv.featured, + target: + fv.target.type === "none" + ? `${0}` + : fv.target.type === "smart" + ? "smart" + : `${+fv.target.value}`, //fixedTarget is required when targetType is fixed + videos: fv.videos.map((v) => v.url), + }; + + if (fv.expiration) fund.expiration = fv.expiration; + + const res = await createFund(fund).unwrap(); + + showModal(Prompt, { + type: "success", + children: ( +

+ Your{" "} + + fund + {" "} + is created + {fv.featured ? ( + <> + {" "} + and is now listed in{" "} + funds page + + ) : ( + "" + )} + !. To get access to this fund, kindly login again. +

+ ), + }); + } catch (err) { + handleError(err, { context: "creating fund" }); + } + }; + + return ( +
+
+

Fund information

+ + + + + + + + + + + + {targetType.value === "fixed" && ( + + )} + + + { + banner.onChange(v); + trigger("banner.file"); + }} + onUndo={(e) => { + e.stopPropagation(); + resetField("banner"); + }} + accept={VALID_MIME_TYPES} + aspect={[4, 1]} + classes={{ + container: "mb-4", + dropzone: "aspect-[4/1]", + }} + maxSize={MAX_SIZE_IN_BYTES} + error={errors.banner?.file?.message} + /> + + + { + logo.onChange(v); + trigger("logo.file"); + }} + onUndo={(e) => { + e.stopPropagation(); + resetField("logo"); + }} + accept={VALID_MIME_TYPES} + aspect={[1, 1]} + classes={{ + container: "mb-4", + dropzone: "aspect-[1/1] w-60", + }} + maxSize={MAX_SIZE_IN_BYTES} + error={errors.logo?.file?.message} + /> + + + + + Featured in funds page + + + + +
+ ); +}); diff --git a/src/pages/Funds/CreateFund/EndowmentSelector/EndowmentSelector.tsx b/src/pages/Funds/CreateFund/EndowmentSelector/EndowmentSelector.tsx new file mode 100644 index 0000000000..ce49ebb056 --- /dev/null +++ b/src/pages/Funds/CreateFund/EndowmentSelector/EndowmentSelector.tsx @@ -0,0 +1,108 @@ +import { + Combobox, + ComboboxInput, + Description, + Field, + Label, +} from "@headlessui/react"; +import Image from "components/Image"; +import { Search, X } from "lucide-react"; +import { forwardRef, useState } from "react"; +import type { EndowOption } from "../schema"; +import { Options } from "./Options"; + +type OnChange = (opts: EndowOption[]) => void; +interface Props { + values: EndowOption[]; + onChange: OnChange; + classes?: string; + disabled?: boolean; + error?: string; +} + +type El = HTMLInputElement; + +export const EndowmentSelector = forwardRef((props, ref) => { + const [searchText, setSearchText] = useState(""); + + return ( + + + +
+
+ {props.values.map((v) => ( + + props.onChange( + props.values.filter((v) => v.id !== thisOpt.id) + ) + } + /> + ))} + +
+ + setSearchText(e.target.value)} + ref={ref} + /> +
+
+
+ + +
+

{props.error}

+ + Inclusion as an eligible Fundraiser Index nonprofit is optional for all + Better Giving Nonprofits. If you don't see a nonprofit of interest on + this list, it means that they have not opted-in at this time. + +
+ ); +}); + +interface ISelectedOption extends EndowOption { + onDeselect: (thisOpt: EndowOption) => void; +} + +function SelectedOption({ onDeselect, ...props }: ISelectedOption) { + return ( +
+ + {props.name} + +
+ ); +} diff --git a/src/pages/Funds/CreateFund/EndowmentSelector/Options.tsx b/src/pages/Funds/CreateFund/EndowmentSelector/Options.tsx new file mode 100644 index 0000000000..3b5ea47a6b --- /dev/null +++ b/src/pages/Funds/CreateFund/EndowmentSelector/Options.tsx @@ -0,0 +1,65 @@ +import { ComboboxOption, ComboboxOptions } from "@headlessui/react"; +import Image from "components/Image"; +import { ErrorStatus, Info, LoadingStatus } from "components/Status"; +import useDebouncer from "hooks/useDebouncer"; +import { useEndowmentCardsQuery } from "services/aws/aws"; +import type { EndowOption } from "../schema"; + +interface Props { + searchText: string; + classes?: string; +} + +export function Options({ classes = "", searchText }: Props) { + const [debouncedSearchText, isDebouncing] = useDebouncer(searchText, 200); + + const endowments = useEndowmentCardsQuery({ + query: debouncedSearchText, + page: "1", + fund_opt_in: "true", + }); + + if (endowments.isLoading || isDebouncing) { + return ( + + Loading options... + + ); + } + + if (endowments.isError) { + return ( + + Failed to load endowments + + ); + } + + const endows = endowments.data?.items; + if (!endows) return null; + + if (endows.length === 0) { + return No endowments found; + } + + return ( + + {endows.map((o) => ( + + + {o.name} + + ))} + + ); +} diff --git a/src/pages/Funds/CreateFund/EndowmentSelector/index.ts b/src/pages/Funds/CreateFund/EndowmentSelector/index.ts new file mode 100644 index 0000000000..0a7aa11c3d --- /dev/null +++ b/src/pages/Funds/CreateFund/EndowmentSelector/index.ts @@ -0,0 +1 @@ +export { EndowmentSelector } from "./EndowmentSelector"; diff --git a/src/pages/Funds/CreateFund/GoalSelector.tsx b/src/pages/Funds/CreateFund/GoalSelector.tsx new file mode 100644 index 0000000000..65b06b6570 --- /dev/null +++ b/src/pages/Funds/CreateFund/GoalSelector.tsx @@ -0,0 +1,36 @@ +import { Field, Label, Radio, RadioGroup } from "@headlessui/react"; +import type { TargetType } from "../common"; + +const options: { [T in TargetType]: string } = { + smart: "Use smart milestones", + none: "No goal or progress bar", + fixed: "Set my own goal", +}; + +interface Props { + value: TargetType; + onChange: (type: TargetType) => void; + classes?: string; +} +export default function GoalSelector(props: Props) { + return ( + + {Object.entries(options).map(([value, label]) => ( + + + + + + + ))} + + ); +} diff --git a/src/pages/Funds/CreateFund/index.ts b/src/pages/Funds/CreateFund/index.ts new file mode 100644 index 0000000000..99fb330beb --- /dev/null +++ b/src/pages/Funds/CreateFund/index.ts @@ -0,0 +1 @@ +export { default as Component } from "./CreateFund"; diff --git a/src/pages/Funds/CreateFund/schema.ts b/src/pages/Funds/CreateFund/schema.ts new file mode 100644 index 0000000000..fa25a7a114 --- /dev/null +++ b/src/pages/Funds/CreateFund/schema.ts @@ -0,0 +1,64 @@ +import { richTextContent } from "types/components"; +import * as v from "valibot"; +import { MAX_SIZE_IN_BYTES, VALID_MIME_TYPES, target } from "../common"; +import { video } from "../common/videos"; + +const str = v.pipe(v.string("required"), v.trim()); + +/** not set by user */ +const fileObject = v.object({ + name: str, + publicUrl: str, +}); + +export const imgLink = v.object({ + file: v.pipe( + v.file("required"), + v.mimeType(VALID_MIME_TYPES, "invalid type"), + v.maxSize(MAX_SIZE_IN_BYTES, "exceeds size limit") + ), + preview: v.pipe(str, v.url()), + ...fileObject.entries, +}); + +export const endowOption = v.object({ + id: v.number(), + name: str, + logo: v.optional(v.pipe(str, v.url())), +}); + +export const MAX_DESCRIPTION_CHAR = 500; + +export const schema = v.object({ + name: v.pipe(str, v.nonEmpty("required")), + description: richTextContent({ + maxChars: MAX_DESCRIPTION_CHAR, + required: true, + }), + banner: imgLink, + logo: imgLink, + members: v.pipe( + v.array(endowOption), + v.minLength(1, "must contain at least one endowment"), + v.maxLength(10, "cannot contain more than 10 endowments") + ), + featured: v.boolean(), + expiration: v.optional( + v.lazy((val) => { + if (!val) return v.string(); + return v.pipe( + str, + v.transform((val) => new Date(val)), + v.date("invalid date"), + v.minValue(new Date(), "must be in the future"), + v.transform((val) => val.toISOString()) + ); + }) + ), + target, + videos: v.array(video), +}); + +export interface FundMember extends v.InferOutput {} +export interface EndowOption extends FundMember {} +export interface FV extends v.InferOutput {} diff --git a/src/pages/Funds/CreateFund/useEndow.ts b/src/pages/Funds/CreateFund/useEndow.ts new file mode 100644 index 0000000000..2383ad746a --- /dev/null +++ b/src/pages/Funds/CreateFund/useEndow.ts @@ -0,0 +1,39 @@ +import type { Endow } from "@better-giving/endowment"; +import { useEffect } from "react"; +import { useLazyProfileQuery } from "services/aws/aws"; +import type { FundMember } from "./schema"; + +export type TEndow = Pick; + +export function useEndow( + members: FundMember[], + onEndowSet: (endow: TEndow) => void +) { + /** set donate settings for single endowment */ + const [getEndow] = useLazyProfileQuery(); + const configSource = `${members.at(0)?.id ?? 0}-${members.length}` as const; + + useEffect(() => { + const [id, length] = configSource.split("-"); + const numId = +id; + const numLength = +length; + + if (numId === 0 || numLength === 0) return; + if (numLength > 1) return; + + getEndow( + { + id: numId, + fields: ["hide_bg_tip", "name"], + }, + true + ) + .unwrap() + .then(({ hide_bg_tip, name }) => { + onEndowSet({ + hide_bg_tip, + name, + }); + }); + }, [configSource, onEndowSet, getEndow]); +} diff --git a/src/pages/Funds/EditFund/EditFund.tsx b/src/pages/Funds/EditFund/EditFund.tsx new file mode 100644 index 0000000000..3a1ecc1df9 --- /dev/null +++ b/src/pages/Funds/EditFund/EditFund.tsx @@ -0,0 +1,50 @@ +import { skipToken } from "@reduxjs/toolkit/query"; +import { ErrorStatus, LoadingStatus } from "components/Status"; +import withAuth from "contexts/Auth"; +import { CircleAlert } from "lucide-react"; +import { useParams } from "react-router-dom"; +import { useFundQuery } from "services/aws/funds"; +import { Form } from "./Form"; + +const containerClass = "padded-container mt-8 grid content-start"; +export default withAuth(function EditFund({ user }) { + const { fundId = "" } = useParams(); + + const { data, isLoading, isError } = useFundQuery(fundId || skipToken); + + if (isLoading) { + return ( +
+ Getting fund... +
+ ); + } + + if (isError || !data) { + return ( +
+ Failed to get fund +
+ ); + } + + if (!user.funds.includes(fundId)) { + return ( +
+ +

Unauthorized

+
+ ); + } + + if (!data.active) { + return ( +
+ +

This fund is already closed

+
+ ); + } + + return
; +}); diff --git a/src/pages/Funds/EditFund/FeatureBanner.tsx b/src/pages/Funds/EditFund/FeatureBanner.tsx new file mode 100644 index 0000000000..b6450d59c8 --- /dev/null +++ b/src/pages/Funds/EditFund/FeatureBanner.tsx @@ -0,0 +1,51 @@ +import ExtLink from "components/ExtLink"; +import { Confirmed, Info } from "components/Status"; +import { appRoutes } from "constants/routes"; + +interface Props { + featured: boolean; + fundId: string; + classes?: string; + onToggle: () => void; + isToggling: boolean; +} + +export function FeatureBanner({ classes = "", ...props }: Props) { + return ( +
+ {props.featured ? ( + Your fund is visible in the funds page + ) : ( + + Your endowment is not visible in the funds page + + )} +
+ + + View + +
+
+ ); +} diff --git a/src/pages/Funds/EditFund/Form.tsx b/src/pages/Funds/EditFund/Form.tsx new file mode 100644 index 0000000000..0ab2c22ff6 --- /dev/null +++ b/src/pages/Funds/EditFund/Form.tsx @@ -0,0 +1,214 @@ +import type { SingleFund } from "@better-giving/fundraiser"; +import type { FundUpdate } from "@better-giving/fundraiser/schema"; +import { ControlledImgEditor as ImgEditor } from "components/ImgEditor"; +import Prompt from "components/Prompt"; +import { RichText } from "components/RichText"; +import { NativeField as Field, Form as Frm } from "components/form"; +import { useErrorContext } from "contexts/ErrorContext"; +import { useModalContext } from "contexts/ModalContext"; +import { uploadFile } from "helpers/uploadFile"; +import type { SubmitHandler } from "react-hook-form"; +import { useCloseFundMutation, useEditFundMutation } from "services/aws/funds"; +import { GoalSelector, MAX_SIZE_IN_BYTES, VALID_MIME_TYPES } from "../common"; +import { Videos } from "../common/videos"; +import { FeatureBanner } from "./FeatureBanner"; +import { type FV, MAX_DESCRIPTION_CHARS } from "./schema"; +import { useRhf } from "./useRhf"; + +export function Form({ + classes = "", + ...props +}: SingleFund & { classes?: string }) { + const { showModal } = useModalContext(); + const { handleError } = useErrorContext(); + const rhf = useRhf(props); + + const [editFund, { isLoading: isEditingFund }] = useEditFundMutation(); + const [closeFund, { isLoading: isClosingFund }] = useCloseFundMutation(); + + const onSubmit: SubmitHandler = async ({ + target, + logo, + banner, + ...fv + }) => { + try { + /// BUILD UPDATE /// + const update: FundUpdate = {}; + + if (rhf.dirtyFields.banner && banner.file) { + const uploaded = await uploadFile(banner.file, "bg-funds"); + if (uploaded) update.banner = uploaded.publicUrl; + } + if (rhf.dirtyFields.logo && logo.file) { + const uploaded = await uploadFile(logo.file, "bg-funds"); + if (uploaded) update.logo = uploaded.publicUrl; + } + + if (rhf.dirtyFields.target) { + update.target = + target.type === "none" + ? "0" + : target.type === "smart" + ? "smart" + : target.value; + } + + if (rhf.dirtyFields.name) update.name = fv.name; + if (rhf.dirtyFields.description) + update.description = fv.description.value; + if (rhf.dirtyFields.videos) update.videos = fv.videos.map((v) => v.url); + + await editFund({ + ...update, + id: props.id, + }).unwrap(); + showModal(Prompt, { + type: "success", + children: "Successfully updated fund!", + }); + } catch (err) { + handleError(err, { context: "updating fund" }); + } + }; + + return ( + + { + try { + await editFund({ + id: props.id, + featured: !props.featured, + }).unwrap(); + } catch (err) { + handleError(err, { context: "updating fund" }); + } + }} + classes="my-4" + /> + + + + + + + { + rhf.logo.onChange(v); + rhf.trigger("logo.file"); + }} + onUndo={(e) => { + e.stopPropagation(); + rhf.resetField("logo"); + }} + accept={VALID_MIME_TYPES} + aspect={[1, 1]} + classes={{ container: "w-80 aspect-[1/1]" }} + maxSize={MAX_SIZE_IN_BYTES} + error={rhf.errors.logo?.file?.message} + /> + + + { + rhf.banner.onChange(v); + rhf.trigger("banner.file"); + }} + onUndo={(e) => { + e.stopPropagation(); + rhf.resetField("banner"); + }} + accept={VALID_MIME_TYPES} + aspect={[4, 1]} + classes={{ container: "w-full aspect-[4/1]" }} + maxSize={MAX_SIZE_IN_BYTES} + error={rhf.errors.banner?.file?.message} + /> + + + + {rhf.targetType.value === "fixed" && ( + + )} + +
+ + +
+
+ ); +} diff --git a/src/pages/Funds/EditFund/index.ts b/src/pages/Funds/EditFund/index.ts new file mode 100644 index 0000000000..bbdd2bec33 --- /dev/null +++ b/src/pages/Funds/EditFund/index.ts @@ -0,0 +1 @@ +export { default as Component } from "./EditFund"; diff --git a/src/pages/Funds/EditFund/schema.ts b/src/pages/Funds/EditFund/schema.ts new file mode 100644 index 0000000000..e1346057be --- /dev/null +++ b/src/pages/Funds/EditFund/schema.ts @@ -0,0 +1,39 @@ +import { richTextContent } from "types/components"; +import * as v from "valibot"; +import { MAX_SIZE_IN_BYTES, VALID_MIME_TYPES, target } from "../common"; +import { video } from "../common/videos"; + +const str = v.pipe(v.string(), v.trim()); + +/** not set by user */ +const fileObject = v.object({ + name: str, + publicUrl: str, +}); + +export const imgLink = v.object({ + file: v.optional( + v.pipe( + v.file("required"), + v.mimeType(VALID_MIME_TYPES, "invalid type"), + v.maxSize(MAX_SIZE_IN_BYTES, "exceeds size limit") + ) + ), + preview: v.pipe(str, v.url()), + ...fileObject.entries, +}); + +export const MAX_DESCRIPTION_CHARS = 500; +export const schema = v.object({ + name: v.pipe(str, v.nonEmpty("required")), + description: richTextContent({ + maxChars: MAX_DESCRIPTION_CHARS, + required: true, + }), + target, + videos: v.array(video), + banner: imgLink, + logo: imgLink, +}); + +export type FV = v.InferOutput; diff --git a/src/pages/Funds/EditFund/useRhf.ts b/src/pages/Funds/EditFund/useRhf.ts new file mode 100644 index 0000000000..3610bd207b --- /dev/null +++ b/src/pages/Funds/EditFund/useRhf.ts @@ -0,0 +1,65 @@ +import type { SingleFund } from "@better-giving/fundraiser"; +import { valibotResolver } from "@hookform/resolvers/valibot"; +import { parseContent } from "components/RichText"; +import { useController, useFieldArray, useForm } from "react-hook-form"; +import { type FV, schema } from "./schema"; + +export function useRhf(init: SingleFund) { + const { + register, + handleSubmit, + control, + trigger, + resetField, + formState: { isSubmitting, errors, isDirty, dirtyFields }, + } = useForm({ + resolver: valibotResolver(schema), + values: { + name: init.name, + description: parseContent(init.description), + target: + init.target === "0" + ? { type: "none" } + : init.target === "smart" + ? { type: "smart" } + : { type: "fixed", value: init.target }, + logo: { name: "", preview: init.logo, publicUrl: init.logo }, + banner: { name: "", preview: init.banner, publicUrl: init.banner }, + videos: init.videos.map((v) => ({ url: v })), + }, + }); + + const { field: targetType } = useController({ + control, + name: "target.type", + }); + + const { field: desc } = useController({ + control, + name: "description", + }); + + const { field: logo } = useController({ control, name: "logo" }); + const { field: banner } = useController({ control, name: "banner" }); + const videos = useFieldArray>({ + control: control as any, + name: "videos", + }); + + return { + register, + handleSubmit, + isSubmitting, + errors, + isDirty, + dirtyFields, + trigger, + resetField, + //controllers + targetType, + logo, + banner, + desc, + videos, + }; +} diff --git a/src/pages/Funds/Fund/FundContext.ts b/src/pages/Funds/Fund/FundContext.ts new file mode 100644 index 0000000000..8d0d74ee09 --- /dev/null +++ b/src/pages/Funds/Fund/FundContext.ts @@ -0,0 +1,14 @@ +import type { SingleFund } from "@better-giving/fundraiser"; +import { isEmpty } from "helpers"; +import { createContext, useContext } from "react"; + +export const FundContext = createContext({} as SingleFund); + +export const useFundContext = (): SingleFund => { + const val = useContext(FundContext); + + if (isEmpty(Object.entries(val))) { + throw new Error("useFundContext should only be used inside FundContext"); + } + return val; +}; diff --git a/src/pages/Funds/Fund/PageError.tsx b/src/pages/Funds/Fund/PageError.tsx new file mode 100644 index 0000000000..442c7997ae --- /dev/null +++ b/src/pages/Funds/Fund/PageError.tsx @@ -0,0 +1,18 @@ +import { appRoutes } from "constants/routes"; +import { TriangleAlert } from "lucide-react"; +import { Link } from "react-router-dom"; + +export default function PageError() { + return ( +
+ +

Failed to load nonprofit profile

+ + Back to Marketplace + +
+ ); +} diff --git a/src/pages/Funds/Fund/Skeleton.tsx b/src/pages/Funds/Fund/Skeleton.tsx new file mode 100644 index 0000000000..b16a06bd9e --- /dev/null +++ b/src/pages/Funds/Fund/Skeleton.tsx @@ -0,0 +1,12 @@ +import ContentLoader from "components/ContentLoader"; + +export default function Skeleton() { + return ( +
+ + + + +
+ ); +} diff --git a/src/pages/Funds/Fund/index.tsx b/src/pages/Funds/Fund/index.tsx new file mode 100644 index 0000000000..478285ef93 --- /dev/null +++ b/src/pages/Funds/Fund/index.tsx @@ -0,0 +1,179 @@ +import type { SingleFund } from "@better-giving/fundraiser"; +import { skipToken } from "@reduxjs/toolkit/query"; +import fallback_banner from "assets/images/fallback-banner.png"; +import flying_character from "assets/images/flying-character.png"; +import Image from "components/Image"; +import { RichText } from "components/RichText"; +import Seo from "components/Seo"; +import VerifiedIcon from "components/VerifiedIcon"; +import { Target, toTarget } from "components/target"; +import { APP_NAME, BASE_URL } from "constants/env"; +import { appRoutes } from "constants/routes"; +import { unpack } from "helpers"; +import { ArrowLeft } from "lucide-react"; +import { Link, useParams } from "react-router-dom"; +import { useFundQuery } from "services/aws/funds"; +import PageError from "./PageError"; +import Skeleton from "./Skeleton"; +import { Share } from "./share"; +import { Video } from "./video"; + +const isClosed = (active: boolean, expiration?: string): boolean => { + const isExpired = expiration ? expiration < new Date().toISOString() : false; + return !active || isExpired; +}; + +export function Component() { + const { fundId = "" } = useParams(); + const { isLoading, isError, data } = useFundQuery(fundId || skipToken); + + if (isLoading) return ; + if (isError || !data) return ; + + return ( +
+ +
+
+
+ + + Fundraisers + +
+
+ +
+ {data.verified && ( + + )} + + {data.name} + + {isClosed(data.active, data.expiration) && ( + + closed + + )} +
+

+ + created by: + + + {data.creator || "dev@placeholder.com"} + +

+ +
+ +
+ + {data.videos.map((v, idx) => ( +
+
+ ))} +
+
+ + +

+ Donations go to +

+
+ {data.members.map((m) => ( +
+ + + {m.name} + +
+ ))} +
+ +
+
+
+ ); +} + +interface Classes { + container?: string; + link?: string; + target?: string; +} +interface IDonateSection extends SingleFund { + classes?: Classes | string; +} +function DonateSection(props: IDonateSection) { + const s = unpack(props.classes); + return ( + <> + {props.target && ( + } + progress={props.donation_total_usd} + target={toTarget(props.target)} + classes={`${s.target} ${s.container} w-full`} + /> + )} + + Donate now + + + ); +} diff --git a/src/pages/Funds/Fund/share.tsx b/src/pages/Funds/Fund/share.tsx new file mode 100644 index 0000000000..76e96ae588 --- /dev/null +++ b/src/pages/Funds/Fund/share.tsx @@ -0,0 +1,156 @@ +import facebook from "assets/icons/social/facebook.png"; +import linkedin from "assets/icons/social/linkedin.png"; +import telegram from "assets/icons/social/telegram.png"; +import x from "assets/icons/social/x.png"; +import ExtLink from "components/ExtLink"; +import Modal from "components/Modal"; +import { APP_NAME, BASE_URL } from "constants/env"; +import { useModalContext } from "contexts/ModalContext"; +import { X } from "lucide-react"; +import { useCallback, useState } from "react"; + +interface SocialMedia { + id: "x" | "telegram" | "linkedin" | "fb"; + title: string; + src: string; + size: number; + handle: string; +} + +const socials: SocialMedia[] = [ + { + id: "linkedin", + src: linkedin, + title: "LinkedIn", + size: 24, + handle: APP_NAME, + }, + { id: "fb", src: facebook, title: "Facebook", size: 21, handle: APP_NAME }, + { id: "x", src: x, title: "X", size: 16, handle: "@BetterDotGiving" }, + { + id: "telegram", + src: telegram, + title: "Telegram", + size: 22, + handle: "@bettergiving", + }, +]; + +type ShareProps = { + recipientName: string; + className?: string; +}; + +export function Share(props: ShareProps) { + return ( +
+

+ Spread the word! +

+

+ Encourage your friends to join in and contribute, making a collective + impact through donations. +

+
+ {socials.map((s) => ( + + ))} +
+
+ ); +} + +interface IShare extends SocialMedia { + recipientName: string; +} +function ShareBtn(props: IShare) { + const { showModal } = useModalContext(); + + return ( + + ); +} + +interface IPrompt extends SocialMedia { + recipientName: string; +} +function Prompt({ recipientName, ...social }: IPrompt) { + const { closeModal } = useModalContext(); + + //shareText will always hold some value + const [shareText, setShareText] = useState(""); + const msgRef = useCallback((node: HTMLParagraphElement | null) => { + if (node) { + setShareText(node.innerText); + } + }, []); + + return ( + +
+ Share on {social.title} + +
+

+ Donate to {recipientName} fundraiser + on {social.handle}!{" "} + {`Every gift is invested to provide sustainable funding for nonprofits: Give once, give forever. Help join the cause: ${BASE_URL}.`} +

+ +
+ +
+ Share now +
+
+ ); +} + +function generateShareLink(rawText: string, type: SocialMedia["id"]) { + const encodedText = encodeURIComponent(rawText); + const encodedURL = encodeURIComponent(BASE_URL); + switch (type) { + case "x": + //https://developer.twitter.com/en/docs/twitter-for-websites/tweet-button/guides/web-intent + return `https://x.com/intent/tweet?text=${encodedText}`; + /** + * feed description is depracated + * https://developers.facebook.com/docs/sharing/reference/feed-dialog#response + * NOTE 6/3/2024: must rely on OpenGraph metadata + */ + case "fb": + return `https://www.facebook.com/dialog/share?app_id=1286913222079194&display=popup&href=${encodeURIComponent( + BASE_URL + )}"e=${encodedText}`; + + //https://core.telegram.org/widgets/share#custom-buttons + case "telegram": + return `https://telegram.me/share/url?url=${encodedURL}&text=${encodedText}`; + + //Linkedin + default: + return `https://www.linkedin.com/feed/?shareActive=true&text=${encodedText}`; + } +} diff --git a/src/pages/Funds/Fund/video.tsx b/src/pages/Funds/Fund/video.tsx new file mode 100644 index 0000000000..90dd322008 --- /dev/null +++ b/src/pages/Funds/Fund/video.tsx @@ -0,0 +1,24 @@ +import ReactPlayer from "react-player"; + +interface IVideo { + classes?: string; + url: string; +} +export function Video(props: IVideo) { + return ( + /** @see https://github.com/CookPete/react-player/issues/145 */ +
+ +
+ ); +} diff --git a/src/pages/Funds/Funds.tsx b/src/pages/Funds/Funds.tsx new file mode 100644 index 0000000000..844ebba4a4 --- /dev/null +++ b/src/pages/Funds/Funds.tsx @@ -0,0 +1,23 @@ +import { Search } from "lucide-react"; +import { useState } from "react"; +import Cards from "./Cards"; + +export function Component() { + const [search, setSearch] = useState(""); + return ( +
+
+ + setSearch(e.target.value)} + className="w-full py-2 pr-3 placeholder:text-navy-l3 text-navy-d4 font-medium font-heading" + placeholder="Search fundraiser" + /> +
+ +
+ ); +} diff --git a/src/pages/Funds/common/GoalSelector.tsx b/src/pages/Funds/common/GoalSelector.tsx new file mode 100644 index 0000000000..3165de6b3f --- /dev/null +++ b/src/pages/Funds/common/GoalSelector.tsx @@ -0,0 +1,36 @@ +import { Field, Label, Radio, RadioGroup } from "@headlessui/react"; +import type { TargetType } from "./types"; + +const options: { [T in TargetType]: string } = { + smart: "Use smart milestones", + none: "No goal or progress bar", + fixed: "Set my own goal", +}; + +interface Props { + value: TargetType; + onChange: (type: TargetType) => void; + classes?: string; +} +export function GoalSelector(props: Props) { + return ( + + {Object.entries(options).map(([value, label]) => ( + + + + + + + ))} + + ); +} diff --git a/src/pages/Funds/common/index.ts b/src/pages/Funds/common/index.ts new file mode 100644 index 0000000000..91ff67f7a7 --- /dev/null +++ b/src/pages/Funds/common/index.ts @@ -0,0 +1,14 @@ +import type { ImageMIMEType } from "types/lists"; +export { target, type TargetType } from "./types"; + +export * from "./GoalSelector"; + +export const VALID_MIME_TYPES: ImageMIMEType[] = [ + "image/jpeg", + "image/png", + "image/webp", + "image/svg+xml", +]; + +export const MAX_SIZE_IN_BYTES = 1e6; +export const MAX_CHARS = 4000; diff --git a/src/pages/Funds/common/types.ts b/src/pages/Funds/common/types.ts new file mode 100644 index 0000000000..d7fedb6aa6 --- /dev/null +++ b/src/pages/Funds/common/types.ts @@ -0,0 +1,20 @@ +import * as v from "valibot"; +export const target = v.variant("type", [ + v.object({ + type: v.literal("fixed"), + value: v.pipe( + v.string("required"), + v.nonEmpty("required"), + v.transform((x) => +x), + v.number("invalid number"), + v.minValue(0, "must be greater than 0"), + /** so that the inferred type is string */ + v.transform((x) => x.toString()) + ), + }), + v.object({ type: v.literal("smart"), value: v.optional(v.string()) }), + v.object({ type: v.literal("none"), value: v.optional(v.string()) }), +]); + +export type Target = v.InferOutput; +export type TargetType = Target["type"]; diff --git a/src/pages/Funds/common/videos/index.ts b/src/pages/Funds/common/videos/index.ts new file mode 100644 index 0000000000..baf4b7347d --- /dev/null +++ b/src/pages/Funds/common/videos/index.ts @@ -0,0 +1,2 @@ +export { Videos } from "./videos"; +export { type Video, videoUrl, video } from "./types"; diff --git a/src/pages/Funds/common/videos/list.tsx b/src/pages/Funds/common/videos/list.tsx new file mode 100644 index 0000000000..b5584e4f52 --- /dev/null +++ b/src/pages/Funds/common/videos/list.tsx @@ -0,0 +1,30 @@ +import { Info } from "components/Status"; +import type { UseFieldArrayReturn } from "react-hook-form"; +import type { FV } from "./types"; +import { VideoPreview } from "./video-preview"; + +interface IList extends UseFieldArrayReturn { + classes?: string; +} +export function List({ classes = "", ...fieldArray }: IList) { + if (fieldArray.fields.length === 0) { + return No videos; + } + return ( +
+ {fieldArray.fields.map((v, idx) => ( + { + fieldArray.update(idx, { url }); + }} + onDelete={(idx) => { + fieldArray.remove(idx); + }} + /> + ))} +
+ ); +} diff --git a/src/pages/Funds/common/videos/types.ts b/src/pages/Funds/common/videos/types.ts new file mode 100644 index 0000000000..e52c72d4cf --- /dev/null +++ b/src/pages/Funds/common/videos/types.ts @@ -0,0 +1,16 @@ +import * as v from "valibot"; +const str = v.pipe(v.string("required"), v.trim()); +export const videoUrl = v.pipe( + str, + v.nonEmpty("required"), + v.url("invalid url") +); +export const video = v.object({ + url: videoUrl, +}); + +export interface Video extends v.InferOutput {} + +export interface FV { + videos: Video[]; +} diff --git a/src/pages/Funds/common/videos/video-modal.tsx b/src/pages/Funds/common/videos/video-modal.tsx new file mode 100644 index 0000000000..e4e6d4375a --- /dev/null +++ b/src/pages/Funds/common/videos/video-modal.tsx @@ -0,0 +1,76 @@ +import { valibotResolver } from "@hookform/resolvers/valibot"; +import Modal from "components/Modal"; +import { NativeField as Field } from "components/form"; +import { useModalContext } from "contexts/ModalContext"; +import { X } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { object } from "valibot"; +import { videoUrl } from "./types"; + +interface IVideoModal { + onSubmit: (url: string) => void; + initUrl?: string; +} + +export function VideoModal(props: IVideoModal) { + const { closeModal } = useModalContext(); + + const { + register, + handleSubmit, + formState: { errors, isDirty }, + } = useForm({ + resolver: valibotResolver(object({ url: videoUrl })), + defaultValues: { url: props.initUrl ?? "" }, + }); + + return ( + { + props.onSubmit(fv.url); + closeModal(); + })} + className="fixed-center z-10 grid text-navy-d4 dark:text-white bg-white dark:bg-blue-d4 sm:w-full w-[90vw] sm:max-w-lg rounded overflow-hidden" + > +
+

+ {props.initUrl ? "Edit" : "Add"} video +

+ +
+
+ +
+ +
+ + +
+
+ ); +} diff --git a/src/pages/Funds/common/videos/video-preview.tsx b/src/pages/Funds/common/videos/video-preview.tsx new file mode 100644 index 0000000000..63ccae7b17 --- /dev/null +++ b/src/pages/Funds/common/videos/video-preview.tsx @@ -0,0 +1,68 @@ +import { useModalContext } from "contexts/ModalContext"; +import { Minus, Pencil } from "lucide-react"; +import type { ButtonHTMLAttributes } from "react"; +import ReactPlayer from "react-player"; +import type { Video } from "./types"; +import { VideoModal } from "./video-modal"; + +interface IVideoPreview extends Video { + idx: number; + onEdit: (url: string, idx: number) => void; + onDelete: (idx: number) => void; +} + +export function VideoPreview(props: IVideoPreview) { + const { showModal } = useModalContext(); + return ( +
+
+ + showModal(VideoModal, { + onSubmit: (url) => props.onEdit(url, props.idx), + initUrl: props.url, + }) + } + > + + + props.onDelete(props.idx)}> + + +
+ {/** render only thumbnails on lists */} + {/** @see https://github.com/CookPete/react-player/issues/145 */} +
+ } + /> +
+
+ ); +} + +function CRUDBtn({ + className, + children, + ...props +}: Omit, "type">) { + return ( + + ); +} diff --git a/src/pages/Funds/common/videos/videos.tsx b/src/pages/Funds/common/videos/videos.tsx new file mode 100644 index 0000000000..dff7a07087 --- /dev/null +++ b/src/pages/Funds/common/videos/videos.tsx @@ -0,0 +1,30 @@ +import { useModalContext } from "contexts/ModalContext"; +import { Plus } from "lucide-react"; +import type { UseFieldArrayReturn } from "react-hook-form"; +import { List } from "./list"; +import type { FV } from "./types"; +import { VideoModal } from "./video-modal"; + +interface IVideos extends UseFieldArrayReturn { + classes?: string; +} +export function Videos({ classes = "", ...props }: IVideos) { + const { showModal } = useModalContext(); + return ( +
+
+ + +
+ +
+ ); +} diff --git a/src/pages/Funds/index.tsx b/src/pages/Funds/index.tsx new file mode 100644 index 0000000000..ca4c3f4069 --- /dev/null +++ b/src/pages/Funds/index.tsx @@ -0,0 +1,13 @@ +import { appRoutes } from "constants/routes"; +import { Outlet, type RouteObject } from "react-router-dom"; + +export const fundsRoute: RouteObject = { + path: appRoutes.funds, + element: , + children: [ + { index: true, lazy: () => import("./Funds") }, + { path: ":fundId", lazy: () => import("./Fund") }, + { path: ":fundId/edit", lazy: () => import("./EditFund") }, + { path: "new", lazy: () => import("./CreateFund") }, + ], +}; diff --git a/src/pages/Profile/Body/GeneralInfo/DetailsColumn/DetailsColumn.tsx b/src/pages/Profile/Body/GeneralInfo/DetailsColumn/DetailsColumn.tsx index 359c5d48e4..f8c8591e16 100644 --- a/src/pages/Profile/Body/GeneralInfo/DetailsColumn/DetailsColumn.tsx +++ b/src/pages/Profile/Body/GeneralInfo/DetailsColumn/DetailsColumn.tsx @@ -6,6 +6,7 @@ import type { PropsWithChildren } from "react"; import { Link } from "react-router-dom"; import { useEndowBalanceQuery } from "services/apes"; import { useProfileContext } from "../../../ProfileContext"; +import { Fundraisers } from "./Fundraisers"; import Socials from "./Socials"; import Tags from "./Tags"; @@ -62,6 +63,7 @@ export default function DetailsColumn({ className = "" }) { Claim this organization )} +
); diff --git a/src/pages/Profile/Body/GeneralInfo/DetailsColumn/Fundraisers.tsx b/src/pages/Profile/Body/GeneralInfo/DetailsColumn/Fundraisers.tsx new file mode 100644 index 0000000000..b96119e20b --- /dev/null +++ b/src/pages/Profile/Body/GeneralInfo/DetailsColumn/Fundraisers.tsx @@ -0,0 +1,65 @@ +import type { FundItem } from "@better-giving/fundraiser"; +import { parseContent, toText } from "components/RichText"; +import { Target, toTarget } from "components/target"; +import { appRoutes } from "constants/routes"; +import { Link } from "react-router-dom"; +import { useFundsEndowMemberOfQuery } from "services/aws/endow-funds"; + +interface Props { + endowId: number; + classes?: string; +} +/** fundraisers that `endowId` is the only member of (not an index fund) */ +export function Fundraisers({ endowId, classes = "" }: Props) { + const { data: funds = [] } = useFundsEndowMemberOfQuery({ + endowId, + npoProfileFeatured: true, + }); + + if (funds.length === 0) return null; + + return ( +
+

Fundraisers

+ {funds.map((f) => ( + + ))} +
+ ); +} + +function Fund(props: FundItem) { + return ( +
+ fundraiser logo + + {props.name} + +

+ {toText(parseContent(props.description))} +

+ + + Donate + +
+ ); +} diff --git a/src/pages/StripePaymentStatus.tsx b/src/pages/StripePaymentStatus.tsx index ec9911ed82..19f2b89110 100644 --- a/src/pages/StripePaymentStatus.tsx +++ b/src/pages/StripePaymentStatus.tsx @@ -1,21 +1,13 @@ import { skipToken } from "@reduxjs/toolkit/query"; -import type { PaymentIntent } from "@stripe/stripe-js"; -import LoadText from "components/LoadText"; import QueryLoader from "components/QueryLoader"; -import Seo from "components/Seo"; -import { EMAIL_SUPPORT } from "constants/env"; import { appRoutes, donateWidgetRoutes } from "constants/routes"; -import { CircleX } from "lucide-react"; -import { useCallback, useEffect } from "react"; import { - Link, type LoaderFunction, Navigate, useLoaderData, useOutletContext, } from "react-router-dom"; import { useStripePaymentStatusQuery } from "services/apes"; -import type { GuestDonor } from "types/aws"; import type { DonateThanksState } from "types/pages"; export const loader: LoaderFunction = ({ request }) => { @@ -35,10 +27,6 @@ export function Component() { paymentIntentId ? { paymentIntentId } : skipToken ); - const { refetch } = queryState; - - const handleProcessing = useCallback(() => refetch(), [refetch]); - return ( - {({ - status, - guestDonor, - recipientName, - recipientId, - arrivalDate, - url, - }) => ( - <> - {/** override default scripts when used inside iframe */} - - { + const _to = isInWidget + ? `${appRoutes.donate_widget}/${donateWidgetRoutes.donate_thanks}` + : appRoutes.donate_thanks; + return ( + - - )} + ); + }} ); } - -function Content(props: { - status: PaymentIntent.Status; - onMount: () => void; - isInWidget: boolean; - guestDonor?: GuestDonor; - recipientName?: string; - recipientId?: number; - bankVerificationUrl?: string; - microdepositArrivalDate?: number; -}) { - switch (props.status) { - case "succeeded": - const to = props.isInWidget - ? `${appRoutes.donate_widget}/${donateWidgetRoutes.donate_thanks}` - : appRoutes.donate_thanks; - return ( - - ); - case "processing": - return ; - case "requires_action": - const _to = props.isInWidget - ? `${appRoutes.donate_widget}/${donateWidgetRoutes.donate_thanks}` - : appRoutes.donate_thanks; - return ( - - ); - case "canceled": - return ; - default: - return ; - } -} -function Processing({ onMount = () => {} }) { - useEffect(() => onMount(), [onMount]); - return ( -
- -
- ); -} - -function Unsuccessful({ recipientId }: { recipientId?: number }) { - return ( -
- -

- Donation unsuccessful -

-

- Your donation was not successful, please try again. -

- - Back to the donation page - -
- ); -} - -function SomethingWentWrong({ recipientId }: { recipientId?: number }) { - return ( -
- -

- Something went wrong -

-

- An error occurred. Please retry your donation. If the problem persists, - please get in touch with {EMAIL_SUPPORT}. -

- - Back to the donation page - -
- ); -} diff --git a/src/pages/UserDashboard/Donations/IntentResumer.tsx b/src/pages/UserDashboard/Donations/IntentResumer.tsx deleted file mode 100644 index 2d9ec475cd..0000000000 --- a/src/pages/UserDashboard/Donations/IntentResumer.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { appRoutes } from "constants/routes"; -import { useErrorContext } from "contexts/ErrorContext"; -import { useNavigate } from "react-router-dom"; -import { useLazyIntentQuery } from "services/apes"; - -type Props = { intentId: string; classes?: string }; -export default function IntentResumer({ intentId, classes }: Props) { - const navigate = useNavigate(); - const [getIntent, { isLoading }] = useLazyIntentQuery(); - const { handleError } = useErrorContext(); - - async function resumeIntent() { - try { - const intent = await getIntent({ transactionId: intentId }).unwrap(); - navigate(`${appRoutes.donate}/${intent.endowmentId}`, { - state: intent, - }); - } catch (err) { - handleError(err, "parsed"); - } - } - return ( - - ); -} diff --git a/src/pages/UserDashboard/Donations/MobileTable.tsx b/src/pages/UserDashboard/Donations/MobileTable.tsx index 7733115a68..7cff90f623 100644 --- a/src/pages/UserDashboard/Donations/MobileTable.tsx +++ b/src/pages/UserDashboard/Donations/MobileTable.tsx @@ -12,7 +12,6 @@ import { ArrowDownToLine } from "lucide-react"; import type { PropsWithChildren } from "react"; import { Link } from "react-router-dom"; import type { Donation } from "types/aws"; -import IntentResumer from "./IntentResumer"; import LoadMoreBtn from "./LoadMoreBtn"; import PaymentResumer from "./PaymentResumer"; import { donationMethod, lastHeaderName } from "./common"; @@ -157,7 +156,7 @@ function LastRowContent(props: Donation.Record & { status: Donation.Status }) { } if (props.status === "intent" && props.viaId === "fiat") { - return ; + return <>---; } if (props.status === "intent" && props.viaId !== "fiat") { diff --git a/src/pages/UserDashboard/Donations/Table.tsx b/src/pages/UserDashboard/Donations/Table.tsx index 17d666260b..9fcde8f248 100644 --- a/src/pages/UserDashboard/Donations/Table.tsx +++ b/src/pages/UserDashboard/Donations/Table.tsx @@ -8,7 +8,6 @@ import useSort from "hooks/useSort"; import { ArrowDownToLine, SquareArrowUpRight } from "lucide-react"; import { Link } from "react-router-dom"; import type { Donation } from "types/aws"; -import IntentResumer from "./IntentResumer"; import LoadMoreBtn from "./LoadMoreBtn"; import PaymentResumer from "./PaymentResumer"; import { donationMethod, lastHeaderName } from "./common"; @@ -189,7 +188,7 @@ function LastRowColContent( } if (props.status === "intent" && props.viaId === "fiat") { - return ; + return <>---; } if (props.status === "intent" && props.viaId !== "fiat") { diff --git a/src/pages/UserDashboard/Funds/Fund.tsx b/src/pages/UserDashboard/Funds/Fund.tsx new file mode 100644 index 0000000000..57b603f947 --- /dev/null +++ b/src/pages/UserDashboard/Funds/Fund.tsx @@ -0,0 +1,30 @@ +import { appRoutes } from "constants/routes"; +import { Link } from "react-router-dom"; +import type { UserFund } from "types/aws"; + +export const Fund = (props: UserFund) => ( +
+ + {props.name} +

+ {props.active ? "active" : "closed"} +

+ + edit + + + view + +
+); diff --git a/src/pages/UserDashboard/Funds/Funds.tsx b/src/pages/UserDashboard/Funds/Funds.tsx new file mode 100644 index 0000000000..a2a612c64d --- /dev/null +++ b/src/pages/UserDashboard/Funds/Funds.tsx @@ -0,0 +1,60 @@ +import ContentLoader from "components/ContentLoader"; +import QueryLoader from "components/QueryLoader"; +import { appRoutes } from "constants/routes"; +import { useAuthenticatedUser } from "contexts/Auth"; +import { Link } from "react-router-dom"; +import { useUserFundsQuery } from "services/aws/users"; +import { Fund } from "./Fund"; + +export function Funds() { + const user = useAuthenticatedUser(); + const query = useUserFundsQuery(user.email); + return ( +
+
+

My Fundraisers

+ + Create + +
+ + + + + + + ), + error: "Failed to get fundraisers", + empty: "You currently don't have any fundraisers", + }} + queryState={query} + > + {(funds) => ( + <> + {funds.map((fund) => ( + + ))} + + )} + +
+ ); +} + +export function Skeleton() { + return ( +
+ + +
+ ); +} diff --git a/src/pages/UserDashboard/Funds/index.ts b/src/pages/UserDashboard/Funds/index.ts new file mode 100644 index 0000000000..0f4842aa81 --- /dev/null +++ b/src/pages/UserDashboard/Funds/index.ts @@ -0,0 +1 @@ +export { Funds as Component } from "./Funds"; diff --git a/src/pages/UserDashboard/UserDashboard.tsx b/src/pages/UserDashboard/UserDashboard.tsx index c034a4309b..69ee98f8e6 100644 --- a/src/pages/UserDashboard/UserDashboard.tsx +++ b/src/pages/UserDashboard/UserDashboard.tsx @@ -2,7 +2,7 @@ import Seo from "components/Seo"; import { appRoutes } from "constants/routes"; import withAuth from "contexts/Auth"; import DashboardLayout from "layout/DashboardLayout"; -import type { RouteObject } from "react-router-dom"; +import { Outlet, type RouteObject } from "react-router-dom"; import Donations from "./Donations"; import EditProfile from "./EditProfile"; import Settings from "./Settings"; @@ -30,5 +30,10 @@ export const userDashboardRoute: RouteObject = { { path: routes.edit_profile, element: }, { path: routes.donations, element: }, { path: routes.settings, element: }, + { + path: routes.funds, + element: , + children: [{ index: true, lazy: () => import("./Funds") }], + }, ], }; diff --git a/src/pages/UserDashboard/routes.ts b/src/pages/UserDashboard/routes.ts index 03ab3c6937..cd416935d7 100644 --- a/src/pages/UserDashboard/routes.ts +++ b/src/pages/UserDashboard/routes.ts @@ -1,10 +1,16 @@ import type { LinkGroup } from "layout/DashboardLayout"; -import { CircleDollarSign, CircleUserRound, Settings } from "lucide-react"; +import { + CircleDollarSign, + CircleUserRound, + Heart, + Settings, +} from "lucide-react"; export const routes = { index: "", edit_profile: "edit-profile", donations: "donations", + funds: "funds", settings: "settings", }; @@ -35,6 +41,14 @@ export const linkGroups: LinkGroup[] = [ size: 22, }, }, + { + title: "My fundraisers", + to: routes.funds, + icon: { + fn: Heart, + size: 21, + }, + }, ], }, ]; diff --git a/src/pages/Widget/Preview.tsx b/src/pages/Widget/Preview.tsx index f710b6551b..f2041dfad1 100644 --- a/src/pages/Widget/Preview.tsx +++ b/src/pages/Widget/Preview.tsx @@ -24,7 +24,8 @@ export default function Preview({ classes = "", config }: Props) { source: "bg-widget", mode: "preview", recipient: { - ...endowment, + id: endowment.id.toString(), + name: endowment.name, hide_bg_tip: data?.hide_bg_tip, progDonationsAllowed: data?.progDonationsAllowed, }, diff --git a/src/pages/donate-fund/content.tsx b/src/pages/donate-fund/content.tsx new file mode 100644 index 0000000000..d72c188af4 --- /dev/null +++ b/src/pages/donate-fund/content.tsx @@ -0,0 +1,101 @@ +import type { SingleFund } from "@better-giving/fundraiser"; +import flying_character from "assets/images/flying-character.png"; +import ExtLink from "components/ExtLink"; +import { DappLogo } from "components/Image"; +import { Info } from "components/Status"; +import { Steps } from "components/donation"; +import { INTERCOM_HELP } from "constants/env"; +import { appRoutes } from "constants/routes"; +import { PRIVACY_POLICY } from "constants/urls"; +import { memo } from "react"; +import { Link } from "react-router-dom"; +import FAQ from "./faq"; +import { FundCard } from "./fund-card"; + +const isClosed = (active: boolean, expiration?: string): boolean => { + const isExpired = expiration ? expiration < new Date().toISOString() : false; + return !active || isExpired; +}; + +function Content(fund: SingleFund) { + return ( +
+
+ + + Cancel + +
+
+
+ +
+ {/** small screen but space is still enough to render sidebar */} +
+ {isClosed(fund.active, fund.expiration) ? ( + + This fund is already closed and can't accept any more donations + + ) : ( + + )} +
+ +

+ + Need help? See{" "} + + FAQs + {" "} + or contact us at our Help Center. + + + Have ideas for how we can build a better donation experience?{" "} + Send us feedback. + + + We respect your privacy. To learn more, check out our{" "} + Privacy Policy. + +

+
+
+ ); +} + +//memoize to prevent useEffect ( based on props ) from running when parent re-renders with the same props +export default memo(Content); + +const A: typeof ExtLink = ({ className, ...props }) => { + return ( + + ); +}; diff --git a/src/pages/donate-fund/faq.tsx b/src/pages/donate-fund/faq.tsx new file mode 100644 index 0000000000..ed31f49e9c --- /dev/null +++ b/src/pages/donate-fund/faq.tsx @@ -0,0 +1,177 @@ +import { + Disclosure, + DisclosureButton, + DisclosurePanel, +} from "@headlessui/react"; +import ExtLink from "components/ExtLink"; +import { DrawerIcon } from "components/Icon"; +import { appRoutes } from "constants/routes"; +import { Fragment, type PropsWithChildren } from "react"; +import { Link } from "react-router-dom"; + +interface Props { + classes?: string; + endowId: number; +} + +export default function FAQ({ classes = "", endowId }: Props) { + return ( +
+

Frequently asked questions

+ {faqs(endowId).map((faq) => ( + + {({ open }) => ( + <> + + + {faq.question} + + + + {open && ( + + {faq.paragraphs.map((p, idx) => ( + {p} + ))} + + )} + + )} + + ))} +
+ ); +} + +const faqs = (endowId: number) => [ + { + id: 1, + question: "How does my donation work to benefit nonprofits?", + paragraphs: [ +

+ Donations are made to Altruistic Partners Empowering Society, DBA Better + Giving, a registered charitable 501(c)(3) (EIN 87-3758939). +

, +

+ For immediate donations, Better Giving grants out + the donations to the chosen nonprofit on a weekly basis. +

, +

+ For Sustainability Fund donations, these are + invested as a Board Managed quasi-endowment, and Better Giving grants + out 75% of the yield every quarter to the nonprofit, investing the other + 25% of the yield into the sustainability fund to mitigate against such + as inflation. In this way, donors can give today, but see the impact + continue into the future. +

, + ], + }, + { + id: 2, + question: "Can I receive a tax receipt?", + paragraphs: [ +

Yes, you can!

, +

+ To the extent permitted by law in their own country or state, Donors are + entitled to a tax receipt for the full amount of their donation, which + includes any transaction fee they have incurred. +

, +

+ All Donors are encouraged to consult with their tax preparer regarding + the specific deductibility of their contribution(s). +

, +

+ Donors will need to provide KYD (know your donor) information including + name and address, and can download an annualized cumulative report of + all donations provided to nonprofits within Better Giving through their + 'My Donation' page. Donors can also request further copies of their tax + receipt. +

, +

+ To keep a permanent record of all your donations and print tax receipts, + create your own personal user account{" "} + + here + +

, +

+ Here is a short video showing how to do that:{" "} + + https://youtu.be/74kEk7aQauA + +

, + ], + }, + { + id: 3, + question: "How much does Better Giving charge?", + paragraphs: [ +

+ It is free to set up and use a Better Giving account. No subscriptions. + No upfront costs. No platform fees (unless a nonprofit has opted out of + offering donors a voluntary donation to Better Giving). +

, +

Payment processing fees from 3rd parties may apply.

, + ], + }, + { + id: 4, + question: "How do I donate by Check?", + paragraphs: [ +

+ For gifts by check: Make your check out to{" "} + + Altruistic Partners Empowering Society Inc + + ,
write{" "} + + endowment:{endowId} + + + donation split:__% + {" "} + in the memo section of the check, and send it to:{" "} + + Miscellaneous Account Services +
PNC Bank +
P.O. Box 8108 +
Philadelphia, PA 19101-8108 +
+

, + ], + }, +]; + +function Em({ + classes = "", + children, + intensity = 1, +}: PropsWithChildren & { intensity?: 1 | 2 | 3; classes?: string }) { + return ( + + {children} + + ); +} diff --git a/src/pages/donate-fund/fund-card.tsx b/src/pages/donate-fund/fund-card.tsx new file mode 100644 index 0000000000..8ad2ca4674 --- /dev/null +++ b/src/pages/donate-fund/fund-card.tsx @@ -0,0 +1,45 @@ +import Image from "components/Image"; +import { parseContent, toText } from "components/RichText"; +import { Target } from "components/target"; +import { appRoutes } from "constants/routes"; +import { Link } from "react-router-dom"; + +type Props = { + id: string; + progress: number; + name: string; + logo: string; + tagline?: string; + classes?: string; +}; +export function FundCard({ classes = "", ...props }: Props) { + return ( +
+
+ + + {props.name} + + {props.tagline && ( +

+ {toText(parseContent(props.tagline))} +

+ )} +
+ } + progress={props.progress} + target="smart" + classes="order-1 @xl/fund-card:order-2" + /> +
+ ); +} diff --git a/src/pages/donate-fund/index.tsx b/src/pages/donate-fund/index.tsx new file mode 100644 index 0000000000..9fe3764345 --- /dev/null +++ b/src/pages/donate-fund/index.tsx @@ -0,0 +1,23 @@ +import { skipToken } from "@reduxjs/toolkit/query"; +import QueryLoader from "components/QueryLoader"; +import { useParams } from "react-router-dom"; +import { useFundQuery } from "services/aws/funds"; +import Content from "./content"; + +export function Component() { + const params = useParams<{ id: string }>(); + const query = useFundQuery(params.id || skipToken); + + return ( + + {(fund) => } + + ); +} diff --git a/src/services/apes/apes.ts b/src/services/apes/apes.ts index 94775c0a64..48978c1877 100644 --- a/src/services/apes/apes.ts +++ b/src/services/apes/apes.ts @@ -1,3 +1,4 @@ +import type { DonationIntent } from "@better-giving/donation/intent"; import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; import type { PaymentIntent } from "@stripe/stripe-js"; import { fetchAuthSession } from "aws-amplify/auth"; @@ -8,7 +9,6 @@ import type { RootState } from "store/store"; import { userIsSignedIn } from "types/auth"; import type { Crypto, - DonationIntent, EndowmentBalances, FiatCurrencyData, GuestDonor, @@ -24,12 +24,6 @@ type StripeRequiresBankVerification = { url?: string; }; -type StripePaymentIntentParams = DonationIntent.Fiat & { - type: "one-time" | "subscription"; -}; - -type CreatePayPalOrderParams = DonationIntent.Fiat; - export const apes = createApi({ reducerPath: "apes", baseQuery: fetchBaseQuery({ @@ -68,18 +62,13 @@ export const apes = createApi({ headers: { authorization: TEMP_JWT }, }), }), - createCryptoIntent: builder.query( - { - query: (params) => ({ - url: "crypto-intents", - method: "POST", - headers: { authorization: TEMP_JWT }, - body: JSON.stringify(params), - }), - } - ), - intent: builder.query({ - query: (params) => ({ url: `donation-intents/${params.transactionId}` }), + createCryptoIntent: builder.query({ + query: (params) => ({ + url: "crypto-intents", + method: "POST", + headers: { authorization: TEMP_JWT }, + body: JSON.stringify(params), + }), }), fiatCurrencies: builder.query< { currencies: DetailedCurrency[]; defaultCurr?: DetailedCurrency }, @@ -112,7 +101,7 @@ export const apes = createApi({ }; }, }), - paypalOrder: builder.mutation({ + paypalOrder: builder.mutation({ query: (params) => ({ url: "fiat-donation/paypal/orders/v2", method: "POST", @@ -121,7 +110,7 @@ export const apes = createApi({ }), transformResponse: (res: { orderId: string }) => res.orderId, }), - stripePaymentIntent: builder.query({ + stripePaymentIntent: builder.query({ query: (data) => ({ url: "fiat-donation/stripe", method: "POST", @@ -129,7 +118,7 @@ export const apes = createApi({ }), transformResponse: (res: { clientSecret: string }) => res.clientSecret, }), - chariotGrant: builder.query({ + chariotGrant: builder.query({ query: (data) => ({ url: "fiat-donation/chariot", method: "POST", @@ -163,7 +152,6 @@ export const apes = createApi({ export const { useCapturePayPalOrderMutation, useCreateCryptoIntentQuery, - useLazyIntentQuery, useFiatCurrenciesQuery, useStripePaymentIntentQuery, useLazyChariotGrantQuery, diff --git a/src/services/aws/aws.ts b/src/services/aws/aws.ts index fb2cdb9d08..9e34e4977b 100644 --- a/src/services/aws/aws.ts +++ b/src/services/aws/aws.ts @@ -78,6 +78,10 @@ export const aws = createApi({ "user", "user-bookmarks", "user-endows", + "user-funds", + "endow-funds", + "funds", + "fund", ], reducerPath: "aws", baseQuery: awsBaseQuery, diff --git a/src/services/aws/endow-funds.ts b/src/services/aws/endow-funds.ts new file mode 100644 index 0000000000..92f5cdd4e9 --- /dev/null +++ b/src/services/aws/endow-funds.ts @@ -0,0 +1,50 @@ +import type { FundItem } from "@better-giving/fundraiser"; +import type { FundsEndowMemberOfParams } from "@better-giving/fundraiser/schema"; +import { TEMP_JWT } from "constants/auth"; +import { version as v } from "../helpers"; +import { aws } from "./aws"; + +interface PathParams { + endowId: number; + fundId: string; +} +export const { + useFundsEndowMemberOfQuery, + useOptOutMutation, + useApproveMutation, +} = aws.injectEndpoints({ + endpoints: (builder) => ({ + fundsEndowMemberOf: builder.query< + FundItem[], + { endowId: number } & FundsEndowMemberOfParams + >({ + providesTags: ["endow-funds"], + query: ({ endowId, ...params }) => { + return { + params, + url: `${v(8)}/endowments/${endowId}/funds`, + }; + }, + }), + optOut: builder.mutation({ + invalidatesTags: ["endow-funds"], + query: ({ fundId, endowId }) => { + return { + url: `${v(8)}/endowments/${endowId}/funds/${fundId}/opt-out`, + method: "POST", + headers: { authorization: TEMP_JWT }, + }; + }, + }), + approve: builder.mutation({ + invalidatesTags: ["endow-funds"], + query: ({ fundId, endowId }) => { + return { + url: `${v(8)}/endowments/${endowId}/funds/${fundId}/approve`, + method: "POST", + headers: { authorization: TEMP_JWT }, + }; + }, + }), + }), +}); diff --git a/src/services/aws/funds.ts b/src/services/aws/funds.ts new file mode 100644 index 0000000000..63733b7397 --- /dev/null +++ b/src/services/aws/funds.ts @@ -0,0 +1,69 @@ +import type { FundsPage, SingleFund } from "@better-giving/fundraiser"; +import type { + FundUpdate, + FundsParams, + NewFund, +} from "@better-giving/fundraiser/schema"; +import { TEMP_JWT } from "constants/auth"; +import { version as v } from "../helpers"; +import { aws } from "./aws"; + +export const funds = aws.injectEndpoints({ + endpoints: (builder) => ({ + createFund: builder.mutation<{ id: string }, NewFund>({ + invalidatesTags: ["funds"], + query: (payload) => { + return { + url: `${v(1)}/funds`, + method: "POST", + body: payload, + headers: { authorization: TEMP_JWT }, + }; + }, + }), + editFund: builder.mutation({ + invalidatesTags: ["funds", "fund"], + query: ({ id, ...payload }) => { + return { + url: `${v(1)}/funds/${id}`, + method: "PATCH", + body: payload, + headers: { authorization: TEMP_JWT }, + }; + }, + }), + closeFund: builder.mutation({ + invalidatesTags: ["funds", "fund", "user-funds"], + query: (fundId) => { + return { + url: `${v(1)}/funds/${fundId}/close`, + method: "POST", + headers: { authorization: TEMP_JWT }, + }; + }, + }), + funds: builder.query({ + providesTags: ["funds"], + query: (params) => { + return { + url: `${v(1)}/funds`, + params, + }; + }, + }), + fund: builder.query({ + providesTags: ["fund"], + query: (fundId) => `${v(1)}/funds/${fundId}`, + }), + }), +}); + +export const { + useCreateFundMutation, + useLazyFundsQuery, + useFundsQuery, + useFundQuery, + useEditFundMutation, + useCloseFundMutation, + util: { updateQueryData: updateAwsQueryData }, +} = funds; diff --git a/src/services/aws/users.ts b/src/services/aws/users.ts index 0af463f6bb..98b556a7b3 100644 --- a/src/services/aws/users.ts +++ b/src/services/aws/users.ts @@ -1,5 +1,10 @@ import { TEMP_JWT } from "constants/auth"; -import type { AletPrefUpdate, UserEndow, UserUpdate } from "types/aws"; +import type { + AletPrefUpdate, + UserEndow, + UserFund, + UserUpdate, +} from "types/aws"; import { version as v } from "../helpers"; import { aws } from "./aws"; @@ -23,6 +28,13 @@ const endowAdmins = aws.injectEndpoints({ headers: { authorization: TEMP_JWT }, }), }), + userFunds: builder.query({ + providesTags: ["user-funds"], + query: (userId) => ({ + url: `/${v(3)}/users/${userId}/funds`, + headers: { authorization: TEMP_JWT }, + }), + }), updateUserEndows: builder.mutation< unknown, { userId: string; alertPrefs: AletPrefUpdate[] } @@ -43,5 +55,6 @@ const endowAdmins = aws.injectEndpoints({ export const { useEditUserMutation, useUserEndowsQuery, + useUserFundsQuery, useUpdateUserEndowsMutation, } = endowAdmins; diff --git a/src/slices/auth.ts b/src/slices/auth.ts index 730fb0902f..21b10d789b 100644 --- a/src/slices/auth.ts +++ b/src/slices/auth.ts @@ -32,12 +32,15 @@ export const loadSession = createAsyncThunk( type Payload = { /** csv */ endows?: string; + /** csv */ + funds?: string; "cognito:groups": string[]; email: string; }; const { endows, + funds, "cognito:groups": groups = [], email: userEmail, } = idToken.payload as Payload; @@ -58,6 +61,7 @@ export const loadSession = createAsyncThunk( tokenExpiry: idToken.payload.exp, groups, endowments: endows?.split(",").map(Number) ?? [], + funds: funds?.split(",") ?? [], email: userEmail, firstName: userAttributes.givenName, lastName: userAttributes.familyName, diff --git a/src/styles/components.css b/src/styles/components.css index ab6cb95adc..1bc3bb28eb 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -80,7 +80,7 @@ @apply rounded-lg normal-case text-sm border border-gray-l4 disabled:bg-gray-l3 aria-disabled:bg-gray-l3 enabled:hover:border-gray-l2 enabled:active:border-gray focus-visible:outline focus-visible:outline-2; } .btn-blue { - @apply btn bg-blue-d1 disabled:bg-gray aria-disabled:bg-gray text-white enabled:hover:bg-blue active:bg-blue-d2 focus-visible:ring-blue-l2; + @apply btn bg-blue-d1 disabled:bg-gray aria-disabled:bg-gray text-white enabled:hover:bg-blue active:bg-blue-d2 focus-visible:ring-blue-l2 hover:bg-blue-d2; } .btn-outline-blue { @apply btn disabled:bg-gray-l3 aria-disabled:bg-gray-l3 text-blue-d1 border border-blue-d1 enabled:hover:border-blue; diff --git a/src/types/auth.ts b/src/types/auth.ts index 412e9306ec..91a972f7d6 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -3,6 +3,7 @@ export type AuthenticatedUser = { tokenExpiry: number; groups: string[]; endowments: number[]; + funds: string[]; email: string; firstName?: string; lastName?: string; diff --git a/src/types/aws/ap/index.ts b/src/types/aws/ap/index.ts index 601fb39818..42bbe91a5d 100644 --- a/src/types/aws/ap/index.ts +++ b/src/types/aws/ap/index.ts @@ -22,6 +22,15 @@ export type UserEndow = { }; }; +export interface UserFund { + name: string; + logo: string; + email: string; + /** uuidv4 */ + id: string; + active: boolean; +} + export interface EndowAdmin { email: string; familyName?: string; @@ -60,6 +69,7 @@ export type EndowmentSettingsAttributes = keyof Pick< | "progDonationsAllowed" | "donateMethods" | "increments" + | "fund_opt_in" | "target" >; @@ -101,6 +111,8 @@ export type EndowmentsQueryParams = { countries?: string; //comma separated country names /** boolean csv */ claimed?: string; + /** boolean csv */ + fund_opt_in?: string; }; export type EndowmentBookmark = { diff --git a/src/types/aws/apes/donation.ts b/src/types/aws/apes/donation.ts index 7bc2922016..2b6d51122d 100644 --- a/src/types/aws/apes/donation.ts +++ b/src/types/aws/apes/donation.ts @@ -1,7 +1,3 @@ -import type { DonationSource } from "types/lists"; -import type { Ensure } from "types/utils"; -import type { Token } from "."; - export namespace Donor { export type Title = "Mr" | "Mrs" | "Ms" | "Mx" | ""; export interface Address { @@ -38,58 +34,6 @@ export type ReceiptPayload = { transactionId: string; // tx hash }; -type TributeNotif = { - toFullName: string; - toEmail: string; - /** may be empty */ - fromMsg: string; -}; - -export interface DonationIntent { - transactionId?: string; - programId?: string; - programName?: string; - amount: number; - tipAmount: number; - feeAllowance: number; - endowmentId: number; - /** 1 - 100 */ - splitLiq: number; - source: DonationSource; - donor: Donor; - /** honorary full name - may be empty `""` */ - inHonorOf?: string; - tributeNotif?: TributeNotif; -} - -export namespace DonationIntent { - export type Frequency = "one-time" | "subscription"; - export interface Crypto extends DonationIntent { - denomination: string; - chainId: string; - /** may be empty */ - walletAddress?: string; - chainName: string; - } - - export interface Fiat extends DonationIntent { - /**ISO 3166-1 alpha-3 code. */ - currency: string; - } - - //donation records always have transactionI - - interface ToResumeCrypto extends Ensure { - token: Token; - } - interface ToResumeFiat - extends Ensure, "transactionId"> { - currency: Currency; - frequency: Frequency; - } - export type ToResume = ToResumeCrypto | ToResumeFiat; -} - export type Currency = { /** ISO 3166-1 alpha-3 code */ currency_code: string; diff --git a/src/types/components.ts b/src/types/components.ts index 9530052b50..bf80ba8307 100644 --- a/src/types/components.ts +++ b/src/types/components.ts @@ -59,15 +59,29 @@ export type CurrencyOption = Currency | DetailedCurrency; * length as the text is being typed as a separate parameter and use *it* for * any validation necessary. */ -export type RichTextContent = { - value: string; - /** - * Optional because we don't set the length manually, it is calculated - * by the RichText component itself and updated on every change. - */ - length?: number; +interface RichTextContentOptions { + maxChars?: number; + required?: boolean; +} +export const richTextContent = ({ + maxChars = Number.MAX_SAFE_INTEGER, + required = false, +}: RichTextContentOptions) => { + return v.object({ + value: required + ? v.pipe(v.string("required"), v.nonEmpty("required")) + : v.string("dev:set default value to empty"), + length: v.optional( + v.pipe( + v.number(), + v.maxValue(maxChars, ({ requirement: r }) => `max length is ${r} chars`) + ) + ), + }); }; +export type RichTextContent = v.InferOutput>; + export const donateMethod = v.object({ id: donateMethodId, name: v.string(), diff --git a/src/types/pages.ts b/src/types/pages.ts index 9d3d56a783..3c788952cb 100644 --- a/src/types/pages.ts +++ b/src/types/pages.ts @@ -3,7 +3,5 @@ import type { GuestDonor } from "./aws"; export type DonateThanksState = { guestDonor?: GuestDonor; recipientName?: string; - recipientId?: number; - bankVerificationUrl?: string; - microdepositArrivalDate?: number; + recipientId?: string; }; diff --git a/yarn.lock b/yarn.lock index da5a902faa..c965b39a32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -822,6 +822,16 @@ __metadata: languageName: node linkType: hard +"@better-giving/donation@npm:1.0.1": + version: 1.0.1 + resolution: "@better-giving/donation@npm:1.0.1" + peerDependencies: + "@better-giving/types": 1.0.1 + valibot: 0.42.0 + checksum: 10/3676950a28b053a705bba1c21c0e3514fda27bcf973940ea72eb9c40eb23649f80b2bb8c6b059bb1d09873f7d2d755fc867f138ddded86ed4e2466b42bd9e818 + languageName: node + linkType: hard + "@better-giving/endowment@npm:1.0.29": version: 1.0.29 resolution: "@better-giving/endowment@npm:1.0.29" @@ -833,6 +843,15 @@ __metadata: languageName: node linkType: hard +"@better-giving/fundraiser@npm:1.0.5": + version: 1.0.5 + resolution: "@better-giving/fundraiser@npm:1.0.5" + peerDependencies: + "@better-giving/types": 1.0.1 + checksum: 10/b3641c552805a7c238132a7a0ebe709ee2092ac644661b8a32ad29008485e3c8388eaa54956bfdf0b6b8fe171e29050321f9bfe0a44cb9d73ad88e0d27883ce0 + languageName: node + linkType: hard + "@better-giving/registration@npm:1.0.24": version: 1.0.24 resolution: "@better-giving/registration@npm:1.0.24" @@ -3467,7 +3486,9 @@ __metadata: resolution: "angelprotocol-web-app@workspace:." dependencies: "@better-giving/assets": "npm:1.0.18" + "@better-giving/donation": "npm:1.0.1" "@better-giving/endowment": "npm:1.0.29" + "@better-giving/fundraiser": "npm:1.0.5" "@better-giving/registration": "npm:1.0.24" "@better-giving/schemas": "npm:1.0.1" "@better-giving/types": "npm:1.0.1" @@ -3512,7 +3533,6 @@ __metadata: postcss-import: "npm:16.1.0" qrcode.react: "npm:3.1.0" quill: "npm:2.0.2" - quill-delta: "npm:5.1.0" react: "npm:18.2.0" react-chariot-connect: "npm:1.0.8" react-csv: "npm:2.2.2" @@ -5823,7 +5843,7 @@ __metadata: languageName: node linkType: hard -"quill-delta@npm:5.1.0, quill-delta@npm:^5.1.0": +"quill-delta@npm:^5.1.0": version: 5.1.0 resolution: "quill-delta@npm:5.1.0" dependencies: