= {
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 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
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 (
+
+ );
+});
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 (
+
+ );
+ }
+
+ 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 (
+
+
+
+ {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: