feat(source): initialized donation widget (#194)

* initialized donation widget

* opencollective api calls

* form element and styling

* fix linting

* removed unused import

* 3.0.79

* get opencollectiva api key from app state

* linting
This commit is contained in:
Anton Tranelis 2025-04-17 13:47:31 +01:00 committed by GitHub
parent 3bd22259f9
commit edb0172a8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 256 additions and 3 deletions

4
package-lock.json generated
View File

@ -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",

View File

@ -14,16 +14,22 @@ export function AppShell({
children,
assetsApi,
embedded,
openCollectiveApiKey,
}: {
appName: string
children: React.ReactNode
assetsApi: AssetsApi
embedded?: boolean
openCollectiveApiKey?: string
}) {
return (
<ContextWrapper>
<div className='tw-flex tw-flex-col tw-h-full'>
<SetAppState assetsApi={assetsApi} embedded={embedded} />
<SetAppState
assetsApi={assetsApi}
embedded={embedded}
openCollectiveApiKey={openCollectiveApiKey}
/>
<NavBar appName={appName}></NavBar>
<div id='app-content' className='tw-flex'>
{children}

View File

@ -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 <></>
}

View File

@ -9,6 +9,7 @@ interface AppState {
sideBarOpen: boolean
sideBarSlim: boolean
embedded: boolean
openCollectiveApiKey: string
}
type UseAppManagerResult = ReturnType<typeof useAppManager>
@ -18,6 +19,7 @@ const initialAppState: AppState = {
sideBarOpen: false,
sideBarSlim: false,
embedded: false,
openCollectiveApiKey: '',
}
const AppContext = createContext<UseAppManagerResult>({

View File

@ -45,6 +45,7 @@ export function ProfileForm() {
relations: [] as Item[],
start: '',
end: '',
openCollectiveSlug: '',
})
const [updatePermission, setUpdatePermission] = useState<boolean>(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])

View File

@ -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<React.SetStateAction<any>>
}) => {
return (
<div className='tw-mt-4 tw-space-y-4'>
<div>
<label
htmlFor='OpenCollectiveSlug'
className='tw-block tw-text-sm tw-font-medium tw-text-gray-500 tw-mb-1'
>
Open Collective Slug:
</label>
<TextInput
placeholder='Open Collective Slug'
type='text'
required={false}
defaultValue={state.openCollectiveSlug}
updateFormValue={(v) =>
setState((prevState) => ({
...prevState,
openCollectiveSlug: v,
}))
}
/>
</div>
</div>
)
}

View File

@ -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<T> {
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<AccountData | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const response = await graphqlClient.post<GraphQLResponse<AccountData>>('', {
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 (
<div className='tw-flex tw-justify-center'>
<span className='tw-loading tw-loading-spinner tw-loading-lg tw-text-neutral-content'></span>
</div>
)
if (error) {
return <p className='tw-text-center tw-text-lg tw-text-red-500'>Error: {error}</p>
}
if (!data?.account) {
return (
<p className='tw-text-center tw-text-lg tw-text-red-500'>
No data available for this account.
</p>
)
}
const { stats } = data.account
const balanceValueInCents = stats.balance?.valueInCents ?? 0
const currency = stats.balance?.currency ?? 'USD'
const currentBalance = balanceValueInCents
return (
<div className='tw-mx-6 tw-mb-6'>
<div className='tw-card tw-bg-base-200 tw-w-fit tw-max-w-full tw-shadow'>
<div className='tw-stats tw-bg-base-200 tw-stats-horizontal tw-rounded-b-none'>
<div className='tw-stat tw-p-3'>
<div className='tw-stat-title'>Current Balance</div>
<div className='tw-stat-value tw-text-xl lg:tw-text-3xl'>
{formatCurrency(currentBalance, currency)}
</div>
</div>
<div className='tw-stat tw-p-3'>
<div className='tw-stat-title'>Received</div>
<div className='tw-stat-value tw-text-green-500 tw-text-xl lg:tw-text-3xl'>
{formatCurrency(stats.totalAmountReceived.valueInCents, currency)}
</div>
</div>
<div className='tw-stat tw-p-3'>
<div className='tw-stat-title'>Spent</div>
<div className='tw-stat-value tw-text-red-500 tw-text-xl lg:tw-text-3xl'>
{formatCurrency(stats.totalAmountReceived.valueInCents - currentBalance, currency)}
</div>
</div>
</div>
<hr></hr>
<div className='tw-m-4 tw-items-center'>
<a href={`https://opencollective.com/${slug}/donate`} target='_blank' rel='noreferrer'>
<button className='tw-btn tw-btn-sm tw-btn-primary tw-float-right tw-ml-4'>
Donate
</button>
</a>
<div className='tw-flex-1 tw-mr-4'>
Support{' '}
<a
className='tw-font-bold'
href={`https://opencollective.com/${slug}`}
target='_blank'
rel='noreferrer'
>
{data.account.name}
</a>{' '}
on <span className='tw-font-bold'>Open&nbsp;Collective</span>
</div>
</div>
</div>
</div>
)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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[] = []

View File

@ -19,4 +19,5 @@ export interface FormState {
relations: Item[]
start: string
end: string
openCollectiveSlug: string
}

1
src/types/Item.d.ts vendored
View File

@ -50,6 +50,7 @@ export interface Item {
telephone?: string
next_appointment?: string
gallery?: GalleryItem[]
openCollectiveSlug?: string
// {
// coordinates: [number, number]