diff --git a/package.json b/package.json index 7e91ddd465..bc2fee39e0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ }, "dependencies": { "@better-giving/assets": "1.0.18", + "@better-giving/fundraiser": "1.0.0-rc.7", "@better-giving/registration": "1.0.24", + "@better-giving/types": "1.0.0-rc.2", "@gsap/react": "2.1.1", "@headlessui/react": "2.1.0", "@hookform/error-message": "2.0.1", diff --git a/src/App/App.tsx b/src/App/App.tsx index f914520b4d..511fa6a19d 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"; @@ -38,6 +39,7 @@ const widgetRoutes: RO[] = [ const _appRoutes: RO[] = [ adminRoute, regRoute, + fundsRoute, userDashboardRoute, ...blogRoutes, ...legalRoutes, diff --git a/src/App/Header/UserMenu/EndowmentLink.tsx b/src/App/Header/UserMenu/EndowmentLink.tsx index b9fd53662e..b900804937 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/components/Icon/icons.ts b/src/components/Icon/icons.ts index 223263b370..ece824936d 100644 --- a/src/components/Icon/icons.ts +++ b/src/components/Icon/icons.ts @@ -56,6 +56,7 @@ import { Search, Settings, Shield, + Split, Sprout, SquareArrowOutUpRight, Star, @@ -130,6 +131,7 @@ export const icons = { Save: Save, Search, SecurityScan: Shield, + Split, Sprout, Star, StickyNote, diff --git a/src/constants/routes.ts b/src/constants/routes.ts index aaf32b7caa..e8fc293241 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -25,6 +25,7 @@ export enum appRoutes { nonprofit_info = "/nonprofit", donor_info = "/donor", wp_plugin = "/wp-plugin", + funds = "/funds", } export const adminRoutes = { @@ -38,6 +39,7 @@ export const adminRoutes = { settings: "settings", members: "members", media: "media", + funds: "funds", } as const; export enum donateWidgetRoutes { diff --git a/src/helpers/uploadFiles.ts b/src/helpers/uploadFiles.ts index 1c7a6dcfeb..ddb7724067 100644 --- a/src/helpers/uploadFiles.ts +++ b/src/helpers/uploadFiles.ts @@ -3,7 +3,7 @@ import { version as v } from "services/helpers"; import { isEmpty } from "./isEmpty"; import { jwtToken } from "./jwt-token"; -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..b31486709f --- /dev/null +++ b/src/pages/Admin/Charity/Funds/FundItem.tsx @@ -0,0 +1,124 @@ +import type { FundItem as TFundItem } from "@better-giving/fundraiser"; +import Icon from "components/Icon"; +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 { 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..10dc2e1831 --- /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 ( +
+

My Fundraisers

+ + + + + + + ), + error: "Failed to get fundraisers", + empty: "No fundraisers found.", + }} + 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 200afe6121..4a6942c7e3 100644 --- a/src/pages/Admin/Charity/Settings/Form.tsx +++ b/src/pages/Admin/Charity/Settings/Form.tsx @@ -39,6 +39,7 @@ export default function Form(props: Props) { hide_bg_tip: props.hide_bg_tip ?? false, programDonateDisabled: !(props.progDonationsAllowed ?? true), donateMethods: fill(props.donateMethods), + fundOptIn: props.fund_opt_in ?? false, }, }); @@ -63,7 +64,7 @@ export default function Form(props: Props) { reset(); }} onSubmit={handleSubmit( - async ({ programDonateDisabled, donateMethods, ...fv }) => { + async ({ programDonateDisabled, donateMethods, fundOptIn, ...fv }) => { if (props.id === BG_ID && fv.hide_bg_tip === false) { return displayError( "BG donation flow should not show BG tip screen" @@ -72,6 +73,7 @@ export default function Form(props: Props) { await updateEndow({ ...fv, + fund_opt_in: fundOptIn, progDonationsAllowed: !programDonateDisabled, id: props.id, donateMethods: donateMethods diff --git a/src/pages/Admin/Charity/Settings/Settings.tsx b/src/pages/Admin/Charity/Settings/Settings.tsx index 0c863e0a8a..5e60ea9981 100644 --- a/src/pages/Admin/Charity/Settings/Settings.tsx +++ b/src/pages/Admin/Charity/Settings/Settings.tsx @@ -1,20 +1,21 @@ import { FormError, FormSkeleton } from "components/admin"; import { useEndowment } from "services/aws/useEndowment"; +import type { EndowmentSettingsAttributes } from "types/aws"; import { useAdminContext } from "../../Context"; import Form from "./Form"; +type K = EndowmentSettingsAttributes; +const fields = Object.keys({ + receiptMsg: "", + hide_bg_tip: "", + progDonationsAllowed: "", + donateMethods: "", + fund_opt_in: "", +} satisfies { [k in K]: "" }) as K[]; + export default function Settings() { const { id } = useAdminContext(); - const { - data: endow, - isLoading, - isError, - } = useEndowment({ id }, [ - "receiptMsg", - "hide_bg_tip", - "progDonationsAllowed", - "donateMethods", - ]); + const { data: endow, isLoading, isError } = useEndowment({ id }, fields); if (isLoading) { return ; diff --git a/src/pages/Admin/Charity/Settings/types.ts b/src/pages/Admin/Charity/Settings/types.ts index 22a6e5c16e..d2f85b1817 100644 --- a/src/pages/Admin/Charity/Settings/types.ts +++ b/src/pages/Admin/Charity/Settings/types.ts @@ -3,6 +3,7 @@ import type { TDonateMethod } from "types/components"; export type FV = { receiptMsg: string; hide_bg_tip: boolean; + fundOptIn: boolean; programDonateDisabled: boolean; donateMethods: TDonateMethod[]; }; diff --git a/src/pages/Admin/Charity/index.tsx b/src/pages/Admin/Charity/index.tsx index 6b78e36438..892ed0457c 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.widget_config, 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 c887c261da..c4141090ab 100644 --- a/src/pages/Admin/constants.ts +++ b/src/pages/Admin/constants.ts @@ -95,6 +95,14 @@ const linkGroup3: LinkGroup = { size: 25, }, }, + { + title: "Fundraisers", + to: sidebarRoutes.funds, + icon: { + type: "Heart", + size: 21, + }, + }, ], }; diff --git a/src/pages/Funds/Cards/Card.tsx b/src/pages/Funds/Cards/Card.tsx new file mode 100644 index 0000000000..fb0b8a60ca --- /dev/null +++ b/src/pages/Funds/Cards/Card.tsx @@ -0,0 +1,62 @@ +import type { FundItem } from "@better-giving/fundraiser"; +import flying_character from "assets/images/flying-character.png"; +import Image from "components/Image"; +import VerifiedIcon from "components/VerifiedIcon"; +import { appRoutes } from "constants/routes"; +import { Link } from "react-router-dom"; +import { Progress } from "./Progress"; + +export default function Card({ + name, + logo, + id, + description, + verified, + donation_total_usd, + target, +}: FundItem) { + return ( +
+ + e.currentTarget.classList.add("bg-blue-l3")} + /> +
+ {/* nonprofit NAME */} +

+ {verified && ( + + )} + {name} +

+ +

+ {description} +

+ + +
+ + {/** absolute so above whole `Link` card */} +
+
{/** future: share button */} + + WIP: 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/Progress.tsx b/src/pages/Funds/Cards/Progress.tsx new file mode 100644 index 0000000000..b69300e1ec --- /dev/null +++ b/src/pages/Funds/Cards/Progress.tsx @@ -0,0 +1,55 @@ +import type { FundItem } from "@better-giving/fundraiser"; +import { humanize } from "helpers"; + +export function Progress( + props: Pick +) { + if (props.target === "0") { + if (!props.donation_total_usd) return; + return ( +

+ + ${humanize(props.donation_total_usd, 0, true)} + {" "} + Raised +

+ ); + } + + const progress = props.donation_total_usd; + const milestone = + props.target === "smart" ? nextMilestone(progress) : +props.target; + + return ( +
+
+
+
+

+ + ${humanize(props.donation_total_usd, 0)} + {" "} + Raised +

+

+ ${humanize(milestone, 0)}{" "} + Goal amount +

+
+ ); +} + +function nextMilestone(progress: number): number { + const base = 100; + const multiplier = 2; + let next = base; + + while (next <= progress) { + next *= multiplier; + } + + return next; +} 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..3b03d6362d --- /dev/null +++ b/src/pages/Funds/CreateFund/CreateFund.tsx @@ -0,0 +1,322 @@ +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 { + NativeCheckField as CheckField, + NativeField as Field, + Form, + Label, +} from "components/form"; +import { APP_NAME } from "constants/env"; +import { appRoutes } from "constants/routes"; +import withAuth from "contexts/Auth"; +import { useErrorContext } from "contexts/ErrorContext"; +import { useModalContext } from "contexts/ModalContext"; +import { logger } from "helpers"; +import { getFullURL, uploadFiles } from "helpers/uploadFiles"; +import { useRef } from "react"; +import { type SubmitHandler, useController, useForm } from "react-hook-form"; +import { Link } from "react-router-dom"; +import { useLazyProfileQuery } from "services/aws/aws"; +import { useCreateFundMutation } from "services/aws/funds"; +import { GoalSelector, MAX_SIZE_IN_BYTES, VALID_MIME_TYPES } from "../common"; +import { EndowmentSelector } from "./EndowmentSelector"; +import { type FV, schema } from "./schema"; + +export default withAuth(function CreateFund() { + const { + register, + control, + trigger, + resetField, + handleSubmit, + setValue, + formState: { errors, isSubmitting }, + watch, + } = useForm({ + resolver: valibotResolver(schema), + defaultValues: { + name: "", + description: "", + logo: { preview: "", publicUrl: "" }, + banner: { preview: "", publicUrl: "" }, + featured: true, + members: [], + settings: { + from: "fund", + allowBgTip: true, + }, + target: { + type: "smart", + }, + }, + }); + 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 customAllowBgTipRef = useRef(true); + const endowReqRef = useRef(); + + const [getEndow] = useLazyProfileQuery(); + const [createFund] = useCreateFundMutation(); + const { handleError } = useErrorContext(); + const { showModal } = useModalContext(); + const settings = watch("settings"); + + const onSubmit: SubmitHandler = async ({ banner, logo, ...fv }) => { + try { + if (!banner.file || !logo.file) { + throw `dev: banner must be required`; + } + + showModal(Prompt, { type: "loading", children: "Uploading..." }); + + const uploadBaseUrl = await uploadFiles( + [banner.file, logo.file], + "bg-funds" + ); + if (!uploadBaseUrl) throw `upload failed`; + + showModal(Prompt, { type: "loading", children: "Creating fund..." }); + + const fund: NewFund = { + name: fv.name, + description: fv.description, + banner: getFullURL(uploadBaseUrl, banner.file.name), + logo: getFullURL(uploadBaseUrl, logo.file.name), + members: fv.members.map((m) => m.id), + featured: fv.featured, + settings: { + allowBgTip: fv.settings.allowBgTip, + }, + target: + fv.target.type === "none" + ? `${0}` + : fv.target.type === "smart" + ? "smart" + : `${+fv.target.value}`, //fixedTarget is required when targetType is fixed + }; + + 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

+ + + + + { + members.onChange(curr); + if (curr.length === 0 || curr.length > 1) { + //invalidate pending request + endowReqRef.current = undefined; + return setValue("settings", { + from: "fund", + allowBgTip: customAllowBgTipRef.current, + }); + } + + //set settings if applicable + try { + const [opt] = curr; + const endowReq = getEndow( + { + id: opt.id, + fields: ["hide_bg_tip", "name"], + }, + true + ); + + endowReqRef.current = endowReq.requestId; + + const endow = await endowReq.unwrap(); + + // more recent onChange invalidated this result + if (!endowReqRef.current) return; + + setValue("settings", { + from: endow.name, + allowBgTip: !endow.hide_bg_tip, + }); + } catch (err) { + logger.error(err); + } + }} + error={errors.members?.message} + /> + + + + {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 + + +

Donate form settings

+

+ {settings.from !== "fund" && + `${withPossesive(settings.from)} config has been applied`} +

+ + { + customAllowBgTipRef.current = e.target.checked; + }} + > + Allow tips to {APP_NAME} + +

+ During the donation flow, there is a step in which users can choose to + tip {APP_NAME} any amount they desire alongside their donation to this + fund. The amount they tip will not affect the donation amount this + fund receives. You may choose to turn this step off in the donation + flow and we will instead apply a fixed 2.9% fee to the amount donated + to this fund. +

+ + + +
+ ); +}); + +const withPossesive = (name: string) => + name.endsWith("s") ? `${name}'` : `${name}'s`; diff --git a/src/pages/Funds/CreateFund/EndowmentSelector/EndowmentSelector.tsx b/src/pages/Funds/CreateFund/EndowmentSelector/EndowmentSelector.tsx new file mode 100644 index 0000000000..8d25799357 --- /dev/null +++ b/src/pages/Funds/CreateFund/EndowmentSelector/EndowmentSelector.tsx @@ -0,0 +1,108 @@ +import { + Combobox, + ComboboxInput, + Description, + Field, + Label, +} from "@headlessui/react"; +import Icon from "components/Icon"; +import Image from "components/Image"; +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..9c89c12012 --- /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..dd9a86bca1 --- /dev/null +++ b/src/pages/Funds/CreateFund/schema.ts @@ -0,0 +1,61 @@ +import * as v from "valibot"; +import { MAX_SIZE_IN_BYTES, VALID_MIME_TYPES, target } from "../common"; + +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.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 settings = v.object({ + from: str, + allowBgTip: v.boolean(), +}); + +export const schema = v.object({ + name: v.pipe(str, v.nonEmpty("required")), + description: v.pipe(str, v.nonEmpty("required")), + banner: imgLink, + logo: imgLink, + members: v.pipe( + v.array(endowOption), + v.minLength(1, "must contain at least one endowment") + ), + featured: v.boolean(), + settings, + 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, +}); + +export interface FundMember extends v.InferOutput {} +export interface EndowOption extends FundMember {} +export type FV = v.InferOutput; diff --git a/src/pages/Funds/CreateFund/useEndow.ts b/src/pages/Funds/CreateFund/useEndow.ts new file mode 100644 index 0000000000..c16510499b --- /dev/null +++ b/src/pages/Funds/CreateFund/useEndow.ts @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { useLazyProfileQuery } from "services/aws/aws"; +import type { Endowment } from "types/aws"; +import type { FundMember } from "./schema"; + +export type Endow = Pick; + +export function useEndow( + members: FundMember[], + onEndowSet: (endow: Endow) => 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..2c2bcbfbce --- /dev/null +++ b/src/pages/Funds/EditFund/EditFund.tsx @@ -0,0 +1,50 @@ +import { skipToken } from "@reduxjs/toolkit/query"; +import Icon from "components/Icon"; +import { ErrorStatus, LoadingStatus } from "components/Status"; +import withAuth from "contexts/Auth"; +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..16fb14ce1d --- /dev/null +++ b/src/pages/Funds/EditFund/Form.tsx @@ -0,0 +1,219 @@ +import type { SingleFund } from "@better-giving/fundraiser"; +import type { FundUpdate } from "@better-giving/fundraiser/schema"; +import { + ControlledImgEditor as ImgEditor, + type ImgLink, +} from "components/ImgEditor"; +import Prompt from "components/Prompt"; +import { NativeField as Field, Form as Frm } from "components/form"; +import { useErrorContext } from "contexts/ErrorContext"; +import { useModalContext } from "contexts/ModalContext"; +import { getFullURL, uploadFiles } from "helpers/uploadFiles"; +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 { FeatureBanner } from "./FeatureBanner"; +import type { FV } 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 { + const [bannerUrl, logoUrl] = await uploadImgs([banner, logo], () => { + showModal( + Prompt, + { type: "loading", children: "Uploading images.." }, + { isDismissible: false } + ); + }); + + /// BUILD UPDATE /// + const update: FundUpdate = {}; + + if (rhf.dirtyFields.target) { + update.target = + target.type === "none" + ? "0" + : target.type === "smart" + ? "smart" + : target.value; + } + + if (rhf.dirtyFields.banner) update.banner = bannerUrl; + if (rhf.dirtyFields.logo) update.logo = logoUrl; + if (rhf.dirtyFields.name) update.name = fv.name; + if (rhf.dirtyFields.description) update.description = fv.description; + + 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" && ( + + )} + +
+ + +
+
+ ); +} + +async function uploadImgs( + imgs: ImgLink[], + onUpload: () => void +): Promise { + const files = imgs.flatMap((img) => (img.file ? [img.file] : [])); + if (files.length > 0) onUpload(); + const baseURL = await uploadFiles(files, "endow-profiles"); + return imgs.map((img) => + img.file && baseURL ? getFullURL(baseURL, img.file.name) : img.publicUrl + ); +} 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..123e3e8323 --- /dev/null +++ b/src/pages/Funds/EditFund/schema.ts @@ -0,0 +1,32 @@ +import * as v from "valibot"; +import { MAX_SIZE_IN_BYTES, VALID_MIME_TYPES, target } from "../common"; + +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 schema = v.object({ + name: v.pipe(str, v.nonEmpty("required")), + description: v.pipe(str, v.nonEmpty("required")), + target, + 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..37d8063422 --- /dev/null +++ b/src/pages/Funds/EditFund/useRhf.ts @@ -0,0 +1,52 @@ +import type { SingleFund } from "@better-giving/fundraiser"; +import { valibotResolver } from "@hookform/resolvers/valibot"; +import { useController, 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: 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 }, + }, + }); + + const { field: targetType } = useController({ + control, + name: "target.type", + }); + + const { field: logo } = useController({ control, name: "logo" }); + const { field: banner } = useController({ control, name: "banner" }); + + return { + register, + handleSubmit, + isSubmitting, + errors, + isDirty, + dirtyFields, + trigger, + resetField, + //controllers + targetType, + logo, + banner, + }; +} diff --git a/src/pages/Funds/Fund/Body/Body.tsx b/src/pages/Funds/Fund/Body/Body.tsx new file mode 100644 index 0000000000..87bd97d5ed --- /dev/null +++ b/src/pages/Funds/Fund/Body/Body.tsx @@ -0,0 +1,61 @@ +import Breadcrumbs from "components/Breadcrumbs"; +import VerifiedIcon from "components/VerifiedIcon"; +import { appRoutes } from "constants/routes"; +import { useFundContext } from "../FundContext"; +import DonateButton from "./DonateButton"; +import GeneralInfo from "./GeneralInfo"; + +export function Body() { + const p = useFundContext(); + + return ( +
+
+ + + +
+
+
+

+ {p.verified && ( + + )} + {p.name} + {!p.active && ( + + closed + + )} +

+
+ +
+              {p.members.map((m) => `${m.id}:${m.name}`).join()}
+            
+
+
+ info 1 + info 2 + info 3 +
+
+ + +
+
+ ); +} diff --git a/src/pages/Funds/Fund/Body/DonateButton.tsx b/src/pages/Funds/Fund/Body/DonateButton.tsx new file mode 100644 index 0000000000..da2d53daa9 --- /dev/null +++ b/src/pages/Funds/Fund/Body/DonateButton.tsx @@ -0,0 +1,17 @@ +import { appRoutes } from "constants/routes"; +import { Link } from "react-router-dom"; +import { useFundContext } from "../FundContext"; + +export default function DonateButton({ className = "" }) { + const fund = useFundContext(); + + return ( + + Donate now + + ); +} diff --git a/src/pages/Funds/Fund/Body/GeneralInfo/DetailsColumn/Balances.tsx b/src/pages/Funds/Fund/Body/GeneralInfo/DetailsColumn/Balances.tsx new file mode 100644 index 0000000000..09e7b93154 --- /dev/null +++ b/src/pages/Funds/Fund/Body/GeneralInfo/DetailsColumn/Balances.tsx @@ -0,0 +1,25 @@ +import { humanize } from "helpers"; +import { useFundContext } from "../../../FundContext"; + +export default function Balances() { + const fund = useFundContext(); + + return ( +
+ +
+ ); +} + +function Balance(props: { title: string; amount: number }) { + return ( +
+

+ {props.title} +

+

+ ${humanize(props.amount)} +

+
+ ); +} diff --git a/src/pages/Funds/Fund/Body/GeneralInfo/DetailsColumn/DetailsColumn.tsx b/src/pages/Funds/Fund/Body/GeneralInfo/DetailsColumn/DetailsColumn.tsx new file mode 100644 index 0000000000..039d030897 --- /dev/null +++ b/src/pages/Funds/Fund/Body/GeneralInfo/DetailsColumn/DetailsColumn.tsx @@ -0,0 +1,12 @@ +import Balances from "./Balances"; + +export default function DetailsColumn({ className = "" }) { + return ( +
+ +
+
+
+
+ ); +} diff --git a/src/pages/Funds/Fund/Body/GeneralInfo/DetailsColumn/index.ts b/src/pages/Funds/Fund/Body/GeneralInfo/DetailsColumn/index.ts new file mode 100644 index 0000000000..dc96b200ca --- /dev/null +++ b/src/pages/Funds/Fund/Body/GeneralInfo/DetailsColumn/index.ts @@ -0,0 +1 @@ +export { default } from "./DetailsColumn"; diff --git a/src/pages/Funds/Fund/Body/GeneralInfo/GeneralInfo.tsx b/src/pages/Funds/Fund/Body/GeneralInfo/GeneralInfo.tsx new file mode 100644 index 0000000000..5ab0322603 --- /dev/null +++ b/src/pages/Funds/Fund/Body/GeneralInfo/GeneralInfo.tsx @@ -0,0 +1,22 @@ +import { useFundContext } from "../../FundContext"; +import Container from "../common/Container"; +import DetailsColumn from "./DetailsColumn"; + +export default function GeneralInfo({ className = "" }) { + const fund = useFundContext(); + + return ( +
+
+ +
+            {fund.description}
+          
+
+
+ +
+ ); +} diff --git a/src/pages/Funds/Fund/Body/GeneralInfo/index.ts b/src/pages/Funds/Fund/Body/GeneralInfo/index.ts new file mode 100644 index 0000000000..99a2cad797 --- /dev/null +++ b/src/pages/Funds/Fund/Body/GeneralInfo/index.ts @@ -0,0 +1 @@ +export { default } from "./GeneralInfo"; diff --git a/src/pages/Funds/Fund/Body/common/Container.tsx b/src/pages/Funds/Fund/Body/common/Container.tsx new file mode 100644 index 0000000000..6c6ff54db4 --- /dev/null +++ b/src/pages/Funds/Fund/Body/common/Container.tsx @@ -0,0 +1,70 @@ +import Icon from "components/Icon"; +import type React from "react"; +import { type PropsWithChildren, useState } from "react"; + +type Props = PropsWithChildren<{ + title: string; + expanded?: true; + classes?: string; +}>; + +export default function Container({ + expanded, + title, + children, + classes = "", +}: Props) { + const [isOpen, setOpen] = useState(true); + + return ( +
+ {expanded ? ( + + ) : ( +
setOpen((prev) => !prev)} + /> + )} + {isOpen && children} +
+ ); +} + +type HeaderProps = { + classes?: string; + title: string; + children?: React.ReactNode; +}; + +function StaticHeader({ title, classes = "", children }: HeaderProps) { + return ( +
+ {title} + {children} +
+ ); +} + +function Header(props: { + title: string; + isOpen: boolean; + onClick: () => void; +}) { + return ( + + + + ); +} diff --git a/src/pages/Funds/Fund/Body/index.ts b/src/pages/Funds/Fund/Body/index.ts new file mode 100644 index 0000000000..bc83c678e5 --- /dev/null +++ b/src/pages/Funds/Fund/Body/index.ts @@ -0,0 +1 @@ +export { Body } from "./Body"; 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..8c3321ce08 --- /dev/null +++ b/src/pages/Funds/Fund/PageError.tsx @@ -0,0 +1,18 @@ +import Icon from "components/Icon"; +import { appRoutes } from "constants/routes"; +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..93365e847f --- /dev/null +++ b/src/pages/Funds/Fund/Skeleton.tsx @@ -0,0 +1,40 @@ +import ContentLoader from "components/ContentLoader"; + +export default function Skeleton() { + return ( +
+ + +
+ + {/** header */} + + {/** content */} + + + {/** balances */} +
+ + + +
+
+
+ ); +} + +function Banner() { + return ( +
+ ); +} + +function Logo({ className = "" }) { + return ( +
+
+
+ ); +} diff --git a/src/pages/Funds/Fund/index.tsx b/src/pages/Funds/Fund/index.tsx new file mode 100644 index 0000000000..96db9fbb5a --- /dev/null +++ b/src/pages/Funds/Fund/index.tsx @@ -0,0 +1,48 @@ +import fallback_banner from "assets/images/fallback-banner.png"; +import flying_character from "assets/images/flying-character.png"; +import Image from "components/Image"; +import Seo from "components/Seo"; +import { APP_NAME, BASE_URL } from "constants/env"; + +import { skipToken } from "@reduxjs/toolkit/query"; +import { useParams } from "react-router-dom"; +import { useFundQuery } from "services/aws/funds"; +import { Body } from "./Body"; +import { FundContext } from "./FundContext"; +import PageError from "./PageError"; +import Skeleton from "./Skeleton"; + +export function Component() { + const { fundId = "" } = useParams(); + const { isLoading, isError, data } = useFundQuery(fundId || skipToken); + + if (isLoading) return ; + if (isError || !data) return ; + + return ( + + +
+
+
+ +
+ +
+
+ ); +} diff --git a/src/pages/Funds/Funds.tsx b/src/pages/Funds/Funds.tsx new file mode 100644 index 0000000000..9b7b32c510 --- /dev/null +++ b/src/pages/Funds/Funds.tsx @@ -0,0 +1,23 @@ +import Icon from "components/Icon"; +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 funds..." + /> +
+ +
+ ); +} 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/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/Registration/Steps/ContactDetails/Form/index.tsx b/src/pages/Registration/Steps/ContactDetails/Form/index.tsx index 08588b9a00..deb9809056 100644 --- a/src/pages/Registration/Steps/ContactDetails/Form/index.tsx +++ b/src/pages/Registration/Steps/ContactDetails/Form/index.tsx @@ -60,8 +60,6 @@ export default function Form({ classes = "" }: { classes?: string }) { } }; - console.log({ errors }); - return ( ( +
+ + {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..6fa8c26122 --- /dev/null +++ b/src/pages/UserDashboard/Funds/Funds.tsx @@ -0,0 +1,50 @@ +import ContentLoader from "components/ContentLoader"; +import QueryLoader from "components/QueryLoader"; +import { useAuthenticatedUser } from "contexts/Auth"; +import { useUserFundsQuery } from "services/aws/users"; +import { Fund } from "./Fund"; + +export function Funds() { + const user = useAuthenticatedUser(); + const query = useUserFundsQuery(user.email); + return ( +
+

My Fundraisers

+ + + + + + + ), + 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 59580fe32a..0323dc629b 100644 --- a/src/pages/UserDashboard/UserDashboard.tsx +++ b/src/pages/UserDashboard/UserDashboard.tsx @@ -1,7 +1,7 @@ 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"; @@ -25,5 +25,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 74daf43c28..fd99a7edad 100644 --- a/src/pages/UserDashboard/routes.ts +++ b/src/pages/UserDashboard/routes.ts @@ -4,6 +4,7 @@ export const routes = { index: "", edit_profile: "edit-profile", donations: "donations", + funds: "funds", settings: "settings", }; @@ -34,6 +35,14 @@ export const linkGroups: LinkGroup[] = [ size: 22, }, }, + { + title: "My fundraisers", + to: routes.funds, + icon: { + type: "Heart", + size: 21, + }, + }, ], }, ]; diff --git a/src/services/aws/aws.ts b/src/services/aws/aws.ts index bac679d6a9..1edda4a257 100644 --- a/src/services/aws/aws.ts +++ b/src/services/aws/aws.ts @@ -76,6 +76,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..27ac3949b0 --- /dev/null +++ b/src/services/aws/endow-funds.ts @@ -0,0 +1,45 @@ +import type { FundItem } from "@better-giving/fundraiser"; +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({ + providesTags: ["endow-funds"], + query: ({ endowId }) => { + return { + 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/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 037a58fa6f..45cf3405f2 100644 --- a/src/types/aws/ap/index.ts +++ b/src/types/aws/ap/index.ts @@ -103,6 +103,7 @@ export type Endowment = { //can be optional, default false and need not be explicit hide_bg_tip?: boolean; published?: boolean; + fund_opt_in?: boolean; /** allowed by default */ progDonationsAllowed?: boolean; donateMethods?: DonateMethodId[]; @@ -127,6 +128,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; @@ -152,7 +162,11 @@ export type EndowmentOption = Pick; export type EndowmentSettingsAttributes = Extract< keyof Endowment, - "receiptMsg" | "hide_bg_tip" | "progDonationsAllowed" | "donateMethods" + | "receiptMsg" + | "hide_bg_tip" + | "progDonationsAllowed" + | "donateMethods" + | "fund_opt_in" >; //most are optional except id, but typed as required to force setting of default values - "", [], etc .. export type EndowmentProfileUpdate = Except< @@ -201,6 +215,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/yarn.lock b/yarn.lock index a7b866cb6c..c7d6250de8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -822,6 +822,16 @@ __metadata: languageName: node linkType: hard +"@better-giving/fundraiser@npm:1.0.0-rc.7": + version: 1.0.0-rc.7 + resolution: "@better-giving/fundraiser@npm:1.0.0-rc.7" + peerDependencies: + "@better-giving/types": 1.0.0-rc.2 + valibot: 0.42.0 + checksum: 10/5a27fa324891e330139eb50007ec1cafb242d3502573c5a03e6a29de608623395f5a0bd916b161f5a7eefb362e9d5094971d1cf8faa3fadc00896c4495dad4a5 + languageName: node + linkType: hard + "@better-giving/registration@npm:1.0.24": version: 1.0.24 resolution: "@better-giving/registration@npm:1.0.24" @@ -831,6 +841,15 @@ __metadata: languageName: node linkType: hard +"@better-giving/types@npm:1.0.0-rc.2": + version: 1.0.0-rc.2 + resolution: "@better-giving/types@npm:1.0.0-rc.2" + peerDependencies: + valibot: 0.42.0 + checksum: 10/a4b46d4fb5fce7d21982f636ff922f9a335ab754030142ef731944b8a4e682e0b3c19a133dc9d9e05ad8dbb32e513dcb66e59007393ce7ead474ed8559d36174 + languageName: node + linkType: hard + "@biomejs/biome@npm:1.8.1": version: 1.8.1 resolution: "@biomejs/biome@npm:1.8.1" @@ -3426,7 +3445,9 @@ __metadata: resolution: "angelprotocol-web-app@workspace:." dependencies: "@better-giving/assets": "npm:1.0.18" + "@better-giving/fundraiser": "npm:1.0.0-rc.7" "@better-giving/registration": "npm:1.0.24" + "@better-giving/types": "npm:1.0.0-rc.2" "@biomejs/biome": "npm:1.8.1" "@gsap/react": "npm:2.1.1" "@headlessui/react": "npm:2.1.0"