From 37de635edf2948b872517e460c25970667a47d16 Mon Sep 17 00:00:00 2001 From: matty Date: Sat, 14 Feb 2026 15:46:38 +0000 Subject: [PATCH] Implement Patron in FE --- .../src/components/account-hover-card.tsx | 12 ++- packages/pl-fe/src/components/account.tsx | 13 ++- .../src/components/dropdown-navigation.tsx | 10 ++ .../pl-fe/src/components/patron-indicator.tsx | 17 ++++ .../pl-fe/src/components/ui/progress-bar.tsx | 12 ++- .../features/account/components/header.tsx | 29 +++--- .../patron/components/funding-panel.tsx | 97 +++++++++++++++++++ .../components/panels/profile-info-panel.tsx | 8 ++ .../ui/components/panels/user-panel.tsx | 24 +++-- .../src/features/ui/util/async-components.ts | 1 + packages/pl-fe/src/layouts/home-layout.tsx | 4 + packages/pl-fe/src/locales/en.json | 3 + .../pl-fe/src/normalizers/frontend-config.ts | 3 + .../src/queries/patron/use-patron-instance.ts | 22 +++++ .../src/queries/patron/use-patron-user.ts | 22 +++++ packages/pl-fe/src/schemas/patron.ts | 39 ++++++++ packages/pl-fe/tailwind.config.ts | 5 + 17 files changed, 292 insertions(+), 29 deletions(-) create mode 100644 packages/pl-fe/src/components/patron-indicator.tsx create mode 100644 packages/pl-fe/src/features/patron/components/funding-panel.tsx create mode 100644 packages/pl-fe/src/queries/patron/use-patron-instance.ts create mode 100644 packages/pl-fe/src/queries/patron/use-patron-user.ts create mode 100644 packages/pl-fe/src/schemas/patron.ts diff --git a/packages/pl-fe/src/components/account-hover-card.tsx b/packages/pl-fe/src/components/account-hover-card.tsx index 43eb2442b..1d1a3d19e 100644 --- a/packages/pl-fe/src/components/account-hover-card.tsx +++ b/packages/pl-fe/src/components/account-hover-card.tsx @@ -18,7 +18,9 @@ import { isTimezoneLabel } from '@/features/ui/components/profile-field'; import { UserPanel } from '@/features/ui/util/async-components'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useAppSelector } from '@/hooks/use-app-selector'; +import { useFrontendConfig } from '@/hooks/use-frontend-config'; import { accountScrobbleQueryOptions } from '@/queries/accounts/account-scrobble'; +import { usePatronUser } from '@/queries/patron/use-patron-user'; import { useAccountHoverCardActions, useAccountHoverCardStore } from '@/stores/account-hover-card'; import AccountLocalTime from './account-local-time'; @@ -35,6 +37,7 @@ const messages = { const getBadges = ( account?: Pick, + isPatron?: boolean, ): JSX.Element[] => { const badges = []; @@ -44,6 +47,10 @@ const getBadges = ( badges.push(} />); } + if (isPatron) { + badges.push(} />); + } + return badges; }; @@ -61,9 +68,11 @@ const AccountHoverCard: React.FC = ({ visible = true }) => { const { updateAccountHoverCard, closeAccountHoverCard } = useAccountHoverCardActions(); const me = useAppSelector(state => state.me); + const frontendConfig = useFrontendConfig(); const { account } = useAccount(accountId || undefined, { withRelationship: true }); const { data: scrobble } = useQuery(accountScrobbleQueryOptions(account?.id)); - const badges = getBadges(account); + const { data: patronUser } = usePatronUser(frontendConfig.patron.enabled ? account?.url : undefined); + const badges = getBadges(account, patronUser?.is_patron); useEffect(() => { if (accountId) dispatch(fetchRelationships([accountId])); @@ -146,6 +155,7 @@ const AccountHoverCard: React.FC = ({ visible = true }) => { accountId={account.id} action={} badges={badges} + isPatron={patronUser?.is_patron} /> {account.local ? ( diff --git a/packages/pl-fe/src/components/account.tsx b/packages/pl-fe/src/components/account.tsx index b3bfe591b..64ee52ac6 100644 --- a/packages/pl-fe/src/components/account.tsx +++ b/packages/pl-fe/src/components/account.tsx @@ -4,6 +4,7 @@ import React, { useLayoutEffect, useRef, useState } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import HoverAccountWrapper from '@/components/hover-account-wrapper'; +import PatronIndicator from '@/components/patron-indicator'; import Avatar from '@/components/ui/avatar'; import Emoji from '@/components/ui/emoji'; import HStack from '@/components/ui/hstack'; @@ -16,6 +17,8 @@ import Emojify from '@/features/emoji/emojify'; import ActionButton from '@/features/ui/components/action-button'; import { useAcct } from '@/hooks/use-acct'; import { useAppSelector } from '@/hooks/use-app-selector'; +import { useFrontendConfig } from '@/hooks/use-frontend-config'; +import { usePatronUser } from '@/queries/patron/use-patron-user'; import { useSettings } from '@/stores/settings'; import Badge from './badge'; @@ -156,6 +159,8 @@ const Account = ({ const me = useAppSelector((state) => state.me); const username = useAcct(account); + const frontendConfig = useFrontendConfig(); + const { data: patronUser } = usePatronUser(frontendConfig.patron.enabled ? account.url : undefined); const { disableUserProvidedMedia } = useSettings(); const handleAction = () => { @@ -241,7 +246,9 @@ const Account = ({ ) : (
- + + + {emoji && ( {children}} > - + + + {emoji && ( { const instance = useInstance(); const restrictUnauth = instance.pleroma.metadata.restrict_unauthenticated; + const frontendConfig = useFrontendConfig(); const containerRef = React.useRef(null); @@ -195,6 +198,13 @@ const DropdownNavigation: React.FC = React.memo((): JSX.Element | null => { + {frontendConfig.patron.enabled && ( + <> + + + + )} + = ({ isPatron, children }) => { + if (!isPatron) return <>{children}; + return ( +
+ {children} +
+ ); +}; + +export { PatronIndicator as default }; diff --git a/packages/pl-fe/src/components/ui/progress-bar.tsx b/packages/pl-fe/src/components/ui/progress-bar.tsx index 4ccd3cbff..8f55067db 100644 --- a/packages/pl-fe/src/components/ui/progress-bar.tsx +++ b/packages/pl-fe/src/components/ui/progress-bar.tsx @@ -16,9 +16,7 @@ const ProgressBar: React.FC = ({ progress, size = 'md' }) => { const { reduceMotion } = useSettings(); const styles = useSpring({ - from: { width: '0%' }, - to: { width: `${progress}%` }, - reset: true, + to: { width: `${Math.min(progress * 100, 100)}%` }, immediate: reduceMotion, }); @@ -30,9 +28,13 @@ const ProgressBar: React.FC = ({ progress, size = 'md' }) => { })} > + > + {!reduceMotion && ( +
+ )} +
); }; diff --git a/packages/pl-fe/src/features/account/components/header.tsx b/packages/pl-fe/src/features/account/components/header.tsx index beadeba11..66e65440b 100644 --- a/packages/pl-fe/src/features/account/components/header.tsx +++ b/packages/pl-fe/src/features/account/components/header.tsx @@ -13,6 +13,7 @@ import AltIndicator from '@/components/alt-indicator'; import Badge from '@/components/badge'; import DropdownMenu, { Menu } from '@/components/dropdown-menu'; import Icon from '@/components/icon'; +import PatronIndicator from '@/components/patron-indicator'; import StillImage from '@/components/still-image'; import Avatar from '@/components/ui/avatar'; import HStack from '@/components/ui/hstack'; @@ -27,6 +28,7 @@ import SubscriptionButton from '@/features/ui/components/subscription-button'; import { useAppDispatch } from '@/hooks/use-app-dispatch'; import { useClient } from '@/hooks/use-client'; import { useFeatures } from '@/hooks/use-features'; +import { useFrontendConfig } from '@/hooks/use-frontend-config'; import { useOwnAccount } from '@/hooks/use-own-account'; import { useFollowAccountMutation, @@ -39,6 +41,7 @@ import { } from '@/queries/accounts/use-relationship'; import { useChats } from '@/queries/chats'; import { queryClient } from '@/queries/client'; +import { usePatronUser } from '@/queries/patron/use-patron-user'; import { blockDomainMutationOptions, unblockDomainMutationOptions } from '@/queries/settings/domain-blocks'; import { useModalsActions } from '@/stores/modals'; import { useSettings } from '@/stores/settings'; @@ -141,7 +144,9 @@ const Header: React.FC = ({ account }) => { const client = useClient(); const features = useFeatures(); + const frontendConfig = useFrontendConfig(); const { account: ownAccount } = useOwnAccount(); + const { data: patronUser } = usePatronUser(frontendConfig.patron.enabled ? account?.url : undefined); const { mutate: followAccount } = useFollowAccountMutation(account?.id!); const { mutate: unblockAccount } = useUnblockAccountMutation(account?.id!); const { mutate: unmuteAccount } = useUnmuteAccountMutation(account?.id!); @@ -757,17 +762,19 @@ const Header: React.FC = ({ account }) => {
- - - + + + + + {account.verified && (
diff --git a/packages/pl-fe/src/features/patron/components/funding-panel.tsx b/packages/pl-fe/src/features/patron/components/funding-panel.tsx new file mode 100644 index 000000000..b2ed09901 --- /dev/null +++ b/packages/pl-fe/src/features/patron/components/funding-panel.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Button from '@/components/ui/button'; +import HStack from '@/components/ui/hstack'; +import Icon from '@/components/ui/icon'; +import ProgressBar from '@/components/ui/progress-bar'; +import Stack from '@/components/ui/stack'; +import Text from '@/components/ui/text'; +import Widget from '@/components/ui/widget'; +import { usePatronInstance } from '@/queries/patron/use-patron-instance'; + +/** Formats integer to USD string. */ +const moneyFormat = (amount: number): string => ( + new Intl + .NumberFormat('en-US', { + style: 'currency', + currency: 'usd', + notation: 'compact', + }) + .format(amount / 100) +); + +interface IFundingPanel { + /** Renders a compact version without the Widget wrapper, for use in tight spaces like mobile nav. */ + compact?: boolean; +} + +const FundingPanel: React.FC = ({ compact }) => { + const { data: patron } = usePatronInstance(); + + if (!patron || !patron.goals.length) return null; + + const amount = patron.funding.amount; + const goal = patron.goals[0].amount; + const goalText = patron.goals[0].text; + const goalReached = amount >= goal; + + let ratioText; + + if (goalReached) { + ratioText = <>{moneyFormat(goal)} per month — reached!; + } else { + ratioText = <>{moneyFormat(amount)} out of {moneyFormat(goal)} per month; + } + + const handleDonateClick = () => { + window.open(patron.url, '_blank'); + }; + + if (compact) { + return ( + + ); + } + + return ( + } + onActionClick={handleDonateClick} + > + + + {ratioText} + + + + + {goalText && {goalText}} + + + + + ); +}; + +export { FundingPanel as default }; diff --git a/packages/pl-fe/src/features/ui/components/panels/profile-info-panel.tsx b/packages/pl-fe/src/features/ui/components/panels/profile-info-panel.tsx index 7c0e2b6b4..4da2e237b 100644 --- a/packages/pl-fe/src/features/ui/components/panels/profile-info-panel.tsx +++ b/packages/pl-fe/src/features/ui/components/panels/profile-info-panel.tsx @@ -14,7 +14,9 @@ import Text from '@/components/ui/text'; import Emojify from '@/features/emoji/emojify'; import { useAcct } from '@/hooks/use-acct'; import { useAppSelector } from '@/hooks/use-app-selector'; +import { useFrontendConfig } from '@/hooks/use-frontend-config'; import { accountScrobbleQueryOptions } from '@/queries/accounts/account-scrobble'; +import { usePatronUser } from '@/queries/patron/use-patron-user'; import { capitalize } from '@/utils/strings'; import { ProfileField } from '../../util/async-components'; @@ -43,8 +45,10 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => const acct = useAcct(account); const me = useAppSelector(state => state.me); const ownAccount = account?.id === me; + const frontendConfig = useFrontendConfig(); const { data: scrobble } = useQuery(accountScrobbleQueryOptions(account?.id)); + const { data: patronUser } = usePatronUser(frontendConfig.patron.enabled ? account?.url : undefined); const getStaffBadge = (): React.ReactNode => { if (account?.is_admin) { @@ -79,6 +83,10 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => badges.push(staffBadge); } + if (patronUser?.is_patron) { + badges.push(} key='patron' />); + } + return [...badges, ...custom]; }; diff --git a/packages/pl-fe/src/features/ui/components/panels/user-panel.tsx b/packages/pl-fe/src/features/ui/components/panels/user-panel.tsx index 8c4fe5786..f4d2bc9dd 100644 --- a/packages/pl-fe/src/features/ui/components/panels/user-panel.tsx +++ b/packages/pl-fe/src/features/ui/components/panels/user-panel.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useAccount } from '@/api/hooks/accounts/use-account'; +import PatronIndicator from '@/components/patron-indicator'; import StillImage from '@/components/still-image'; import Avatar from '@/components/ui/avatar'; import HStack from '@/components/ui/hstack'; @@ -24,9 +25,10 @@ interface IUserPanel { action?: JSX.Element; badges?: JSX.Element[]; domain?: string; + isPatron?: boolean; } -const UserPanel: React.FC = ({ accountId, action, badges, domain }) => { +const UserPanel: React.FC = ({ accountId, action, badges, domain, isPatron }) => { const intl = useIntl(); const { demetricator, disableUserProvidedMedia } = useSettings(); const { account } = useAccount(accountId); @@ -57,15 +59,17 @@ const UserPanel: React.FC = ({ accountId, action, badges, domain }) title={acct} className='-mt-12 block' > - + + + )} diff --git a/packages/pl-fe/src/features/ui/util/async-components.ts b/packages/pl-fe/src/features/ui/util/async-components.ts index ee8344bbf..ee9135a0d 100644 --- a/packages/pl-fe/src/features/ui/util/async-components.ts +++ b/packages/pl-fe/src/features/ui/util/async-components.ts @@ -110,6 +110,7 @@ export const AccountNotePanel = lazy(() => import('@/features/ui/components/pane export const AnnouncementsPanel = lazy(() => import('@/components/announcements/announcements-panel')); export const BirthdayPanel = lazy(() => import('@/features/ui/components/panels/birthday-panel')); export const CryptoDonatePanel = lazy(() => import('@/features/crypto-donate/components/crypto-donate-panel')); +export const FundingPanel = lazy(() => import('@/features/patron/components/funding-panel')); export const GroupMediaPanel = lazy(() => import('@/features/ui/components/panels/group-media-panel')); export const InstanceInfoPanel = lazy(() => import('@/features/ui/components/panels/instance-info-panel')); export const InstanceModerationPanel = lazy(() => import('@/features/ui/components/panels/instance-moderation-panel')); diff --git a/packages/pl-fe/src/layouts/home-layout.tsx b/packages/pl-fe/src/layouts/home-layout.tsx index af2bd3e4d..7101f9b5f 100644 --- a/packages/pl-fe/src/layouts/home-layout.tsx +++ b/packages/pl-fe/src/layouts/home-layout.tsx @@ -13,6 +13,7 @@ import { SignUpPanel, PromoPanel, CryptoDonatePanel, + FundingPanel, BirthdayPanel, AnnouncementsPanel, ComposeForm, @@ -96,6 +97,9 @@ const HomeLayout = () => { {(hasCrypto && cryptoLimit > 0 && me) && ( )} + {frontendConfig.patron.enabled && me && ( + + )} {features.birthdays && ( diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index 03985fc3a..391f917f1 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -50,6 +50,7 @@ "account.mute": "Mute @{name}", "account.muted": "Muted", "account.never_active": "Never", + "account.patron": "Patron", "account.posts": "Posts", "account.posts_with_replies": "Posts & replies", "account.profile": "Profile", @@ -1378,6 +1379,8 @@ "notifications.queue_label": "Click to see {count} new {count, plural, one {notification} other {notifications}}", "oauth_consumer.tooltip": "Sign in with {provider}", "oauth_consumers.title": "Other ways to sign in", + "patron.donate": "Donate", + "patron.title": "Funding Goal", "password_reset.confirmation": "Check your email for confirmation.", "password_reset.fields.email_placeholder": "E-mail address", "password_reset.fields.username_placeholder": "Email or username", diff --git a/packages/pl-fe/src/normalizers/frontend-config.ts b/packages/pl-fe/src/normalizers/frontend-config.ts index d6f48dee7..837d13315 100644 --- a/packages/pl-fe/src/normalizers/frontend-config.ts +++ b/packages/pl-fe/src/normalizers/frontend-config.ts @@ -64,6 +64,9 @@ const frontendConfigSchema = coerceObject({ cryptoDonatePanel: coerceObject({ limit: v.fallback(v.number(), 1), }), + patron: coerceObject({ + enabled: v.fallback(v.boolean(), false), + }), aboutPages: v.fallback(v.record(v.string(), coerceObject({ defaultLocale: v.fallback(v.string(), ''), // v.fallback(v.optional(v.string()), undefined), locales: filteredArray(v.string()), diff --git a/packages/pl-fe/src/queries/patron/use-patron-instance.ts b/packages/pl-fe/src/queries/patron/use-patron-instance.ts new file mode 100644 index 000000000..b11437e95 --- /dev/null +++ b/packages/pl-fe/src/queries/patron/use-patron-instance.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import * as v from 'valibot'; + +import { getClient } from '@/api'; +import { useFrontendConfig } from '@/hooks/use-frontend-config'; +import { patronInstanceSchema } from '@/schemas/patron'; + +const usePatronInstance = () => { + const frontendConfig = useFrontendConfig(); + + return useQuery({ + queryKey: ['patron', 'instance'], + queryFn: async () => { + const response = await getClient().request('/api/patron/v1/instance'); + return v.parse(patronInstanceSchema, response.json); + }, + enabled: frontendConfig.patron.enabled, + staleTime: 5 * 60 * 1000, + }); +}; + +export { usePatronInstance }; diff --git a/packages/pl-fe/src/queries/patron/use-patron-user.ts b/packages/pl-fe/src/queries/patron/use-patron-user.ts new file mode 100644 index 000000000..de5510e91 --- /dev/null +++ b/packages/pl-fe/src/queries/patron/use-patron-user.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import * as v from 'valibot'; + +import { getClient } from '@/api'; +import { useFrontendConfig } from '@/hooks/use-frontend-config'; +import { patronUserSchema } from '@/schemas/patron'; + +const usePatronUser = (url: string | undefined) => { + const frontendConfig = useFrontendConfig(); + + return useQuery({ + queryKey: ['patron', 'user', url], + queryFn: async () => { + const response = await getClient().request(`/api/patron/v1/accounts/${encodeURIComponent(url!)}`); + return v.parse(patronUserSchema, response.json); + }, + enabled: frontendConfig.patron.enabled && !!url, + staleTime: 5 * 60 * 1000, + }); +}; + +export { usePatronUser }; diff --git a/packages/pl-fe/src/schemas/patron.ts b/packages/pl-fe/src/schemas/patron.ts new file mode 100644 index 000000000..029e47127 --- /dev/null +++ b/packages/pl-fe/src/schemas/patron.ts @@ -0,0 +1,39 @@ +import * as v from 'valibot'; + +import { coerceObject } from '@/schemas/utils'; + +const patronUserSchema = coerceObject({ + is_patron: v.fallback(v.boolean(), false), + url: v.fallback(v.string(), ''), +}); + +type PatronUser = v.InferOutput; + +const patronFundingSchema = coerceObject({ + amount: v.fallback(v.number(), 0), + patrons: v.fallback(v.number(), 0), + currency: v.fallback(v.string(), 'usd'), + interval: v.fallback(v.string(), 'monthly'), +}); + +const patronGoalSchema = coerceObject({ + amount: v.fallback(v.number(), 0), + currency: v.fallback(v.string(), 'usd'), + interval: v.fallback(v.string(), 'monthly'), + text: v.fallback(v.string(), ''), +}); + +const patronInstanceSchema = coerceObject({ + funding: patronFundingSchema, + goals: v.fallback(v.array(patronGoalSchema), []), + url: v.fallback(v.string(), ''), +}); + +type PatronInstance = v.InferOutput; + +export { + patronUserSchema, + patronInstanceSchema, + type PatronUser, + type PatronInstance, +}; diff --git a/packages/pl-fe/tailwind.config.ts b/packages/pl-fe/tailwind.config.ts index 8c9f4db30..315f1d937 100644 --- a/packages/pl-fe/tailwind.config.ts +++ b/packages/pl-fe/tailwind.config.ts @@ -77,6 +77,7 @@ const config: Config = { 'enter': 'enter 200ms ease-out', 'leave': 'leave 150ms ease-in forwards', 'text-overflow': 'text-overflow 8s linear infinite', + 'shimmer': 'shimmer 2.5s ease-in-out infinite', }, keyframes: { 'sonar-scale-4': { @@ -108,6 +109,10 @@ const config: Config = { '10%, 90%': { transform: 'translate(0, 0)', left: '0%' }, '40%, 60%': { transform: 'translate(-100%, 0)', left: '100%' }, }, + 'shimmer': { + '0%': { transform: 'translateX(-100%)' }, + '100%': { transform: 'translateX(100%)' }, + }, }, }, },