diff --git a/package-lock.json b/package-lock.json index bdb936a9..58adfdeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "utopia-ui", - "version": "3.0.80", + "version": "3.0.81", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "utopia-ui", - "version": "3.0.80", + "version": "3.0.81", "license": "GPL-3.0-only", "dependencies": { "@heroicons/react": "^2.0.17", diff --git a/src/Components/AppShell/AppShell.tsx b/src/Components/AppShell/AppShell.tsx index 7c2c482e..ebe297a3 100644 --- a/src/Components/AppShell/AppShell.tsx +++ b/src/Components/AppShell/AppShell.tsx @@ -14,16 +14,22 @@ export function AppShell({ children, assetsApi, embedded, + openCollectiveApiKey, }: { appName: string children: React.ReactNode assetsApi: AssetsApi embedded?: boolean + openCollectiveApiKey?: string }) { return (
- +
{children} diff --git a/src/Components/AppShell/SetAppState.tsx b/src/Components/AppShell/SetAppState.tsx index 055bc561..10b9e4a6 100644 --- a/src/Components/AppShell/SetAppState.tsx +++ b/src/Components/AppShell/SetAppState.tsx @@ -7,9 +7,11 @@ import type { AssetsApi } from '#types/AssetsApi' export const SetAppState = ({ assetsApi, embedded, + openCollectiveApiKey, }: { assetsApi: AssetsApi embedded?: boolean + openCollectiveApiKey?: string }) => { const setAppState = useSetAppState() @@ -21,5 +23,9 @@ export const SetAppState = ({ setAppState({ embedded }) }, [embedded, setAppState]) + useEffect(() => { + setAppState({ openCollectiveApiKey }) + }, [openCollectiveApiKey, setAppState]) + return <> } diff --git a/src/Components/AppShell/hooks/useAppState.tsx b/src/Components/AppShell/hooks/useAppState.tsx index 794fe700..029da5df 100644 --- a/src/Components/AppShell/hooks/useAppState.tsx +++ b/src/Components/AppShell/hooks/useAppState.tsx @@ -9,6 +9,7 @@ interface AppState { sideBarOpen: boolean sideBarSlim: boolean embedded: boolean + openCollectiveApiKey: string } type UseAppManagerResult = ReturnType @@ -18,6 +19,7 @@ const initialAppState: AppState = { sideBarOpen: false, sideBarSlim: false, embedded: false, + openCollectiveApiKey: '', } const AppContext = createContext({ diff --git a/src/Components/Profile/ProfileForm.tsx b/src/Components/Profile/ProfileForm.tsx index 6f84f689..87252764 100644 --- a/src/Components/Profile/ProfileForm.tsx +++ b/src/Components/Profile/ProfileForm.tsx @@ -45,6 +45,7 @@ export function ProfileForm() { relations: [] as Item[], start: '', end: '', + openCollectiveSlug: '', }) const [updatePermission, setUpdatePermission] = useState(false) @@ -137,6 +138,7 @@ export function ProfileForm() { relations, start: item.start ?? '', end: item.end ?? '', + openCollectiveSlug: item.openCollectiveSlug ?? '', }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [item, tags, items]) diff --git a/src/Components/Profile/Subcomponents/CrowdfundingForm.tsx b/src/Components/Profile/Subcomponents/CrowdfundingForm.tsx new file mode 100644 index 00000000..8ae40a15 --- /dev/null +++ b/src/Components/Profile/Subcomponents/CrowdfundingForm.tsx @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { TextInput } from '#components/Input' + +import type { FormState } from '#types/FormState' + +export const CrowdfundingForm = ({ + state, + setState, +}: { + state: FormState + setState: React.Dispatch> +}) => { + return ( +
+
+ + + setState((prevState) => ({ + ...prevState, + openCollectiveSlug: v, + })) + } + /> +
+
+ ) +} diff --git a/src/Components/Profile/Subcomponents/CrowdfundingView.tsx b/src/Components/Profile/Subcomponents/CrowdfundingView.tsx new file mode 100644 index 00000000..b6842a34 --- /dev/null +++ b/src/Components/Profile/Subcomponents/CrowdfundingView.tsx @@ -0,0 +1,192 @@ +import axios from 'axios' +import { useState, useEffect } from 'react' + +import { useAppState } from '#components/AppShell/hooks/useAppState' + +import type { Item } from '#types/Item' + +interface AccountData { + account: { + name: string + type: string + stats: { + balance: { + valueInCents: number + currency: string + } | null + totalAmountReceived: { + valueInCents: number + currency: string + } + totalAmountSpent: { + valueInCents: number + currency: string + } + contributionsCount: number + contributorsCount: number + } + } +} + +interface GraphQLResponse { + data?: T + errors?: { message: string }[] +} + +const GET_TRANSACTIONS = ` + query GetAccountStats($slug: String!) { + account(slug: $slug) { + name + type + stats { + balance { + valueInCents + currency + } + totalAmountReceived(net: true) { + valueInCents + currency + } + totalAmountSpent { + valueInCents + currency + } + contributionsCount + contributorsCount + } + } + } +` + +const formatCurrency = (valueInCents: number, currency: string) => { + const value = valueInCents / 100 + const options: Intl.NumberFormatOptions = { + style: 'currency', + currency, + ...(Math.abs(value) >= 1000 ? { minimumFractionDigits: 0, maximumFractionDigits: 0 } : {}), + } + return new Intl.NumberFormat('de-DE', options).format(value) +} + +export const CrowdfundingView = ({ item }: { item: Item }) => { + // Hier wird slug aus dem Item extrahiert. + const slug = item.openCollectiveSlug + const appState = useAppState() + + const token = appState.openCollectiveApiKey + + const graphqlClient = axios.create({ + baseURL: 'https://api.opencollective.com/graphql/v2', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }) + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchData = async () => { + setLoading(true) + setError(null) + try { + const response = await graphqlClient.post>('', { + query: GET_TRANSACTIONS, + variables: { slug }, + }) + if (response.data.errors?.length) { + setError(response.data.errors[0].message) + } else { + setData(response.data.data ?? null) + } + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message) + } else { + throw err + } + } + setLoading(false) + } + + if (slug) { + void fetchData() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slug]) + + if (!slug) return null + + if (loading) + return ( +
+ +
+ ) + + if (error) { + return

Error: {error}

+ } + + if (!data?.account) { + return ( +

+ No data available for this account. +

+ ) + } + + const { stats } = data.account + const balanceValueInCents = stats.balance?.valueInCents ?? 0 + const currency = stats.balance?.currency ?? 'USD' + const currentBalance = balanceValueInCents + + return ( +
+
+
+
+
Current Balance
+
+ {formatCurrency(currentBalance, currency)} +
+
+
+
Received
+
+ {formatCurrency(stats.totalAmountReceived.valueInCents, currency)} +
+
+
+
Spent
+
+ {formatCurrency(stats.totalAmountReceived.valueInCents - currentBalance, currency)} +
+
+
+
+
+ + + +
+ Support{' '} + + {data.account.name} + {' '} + on Open Collective +
+
+
+
+ ) +} diff --git a/src/Components/Profile/Templates/FlexForm.tsx b/src/Components/Profile/Templates/FlexForm.tsx index 5e8d4408..5c5a8928 100644 --- a/src/Components/Profile/Templates/FlexForm.tsx +++ b/src/Components/Profile/Templates/FlexForm.tsx @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ContactInfoForm } from '#components/Profile/Subcomponents/ContactInfoForm' +import { CrowdfundingForm } from '#components/Profile/Subcomponents/CrowdfundingForm' import { GroupSubheaderForm } from '#components/Profile/Subcomponents/GroupSubheaderForm' import { ProfileStartEndForm } from '#components/Profile/Subcomponents/ProfileStartEndForm' import { ProfileTextForm } from '#components/Profile/Subcomponents/ProfileTextForm' @@ -14,6 +15,7 @@ const componentMap = { texts: ProfileTextForm, contactInfos: ContactInfoForm, startEnd: ProfileStartEndForm, + crowdfundings: CrowdfundingForm, // weitere Komponenten hier } diff --git a/src/Components/Profile/Templates/FlexView.tsx b/src/Components/Profile/Templates/FlexView.tsx index 2d98ec74..7958600e 100644 --- a/src/Components/Profile/Templates/FlexView.tsx +++ b/src/Components/Profile/Templates/FlexView.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ContactInfoView } from '#components/Profile/Subcomponents/ContactInfoView' +import { CrowdfundingView } from '#components/Profile/Subcomponents/CrowdfundingView' import { GalleryView } from '#components/Profile/Subcomponents/GalleryView' import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHeaderView' import { ProfileStartEndView } from '#components/Profile/Subcomponents/ProfileStartEndView' @@ -15,6 +16,7 @@ const componentMap = { contactInfos: ContactInfoView, startEnd: ProfileStartEndView, gallery: GalleryView, + crowdfundings: CrowdfundingView, // weitere Komponenten hier } diff --git a/src/Components/Profile/itemFunctions.ts b/src/Components/Profile/itemFunctions.ts index 7c7443d0..ddf6ca32 100644 --- a/src/Components/Profile/itemFunctions.ts +++ b/src/Components/Profile/itemFunctions.ts @@ -196,6 +196,7 @@ export const onUpdateItem = async ( ...(state.image.length > 10 && { image: state.image }), ...(state.offers.length > 0 && { offers: offerUpdates }), ...(state.needs.length > 0 && { needs: needsUpdates }), + ...(state.openCollectiveSlug && { openCollectiveSlug: state.openCollectiveSlug }), } const offersState: any[] = [] diff --git a/src/types/FormState.d.ts b/src/types/FormState.d.ts index a7e7f1ee..a0ea8830 100644 --- a/src/types/FormState.d.ts +++ b/src/types/FormState.d.ts @@ -19,4 +19,5 @@ export interface FormState { relations: Item[] start: string end: string + openCollectiveSlug: string } diff --git a/src/types/Item.d.ts b/src/types/Item.d.ts index 1dd7bde4..84dab001 100644 --- a/src/types/Item.d.ts +++ b/src/types/Item.d.ts @@ -50,6 +50,7 @@ export interface Item { telephone?: string next_appointment?: string gallery?: GalleryItem[] + openCollectiveSlug?: string // { // coordinates: [number, number]